@@ -10,6 +10,7 @@ import {
1010 Loader2 ,
1111 Copy ,
1212 Clock ,
13+ CheckCircle2 ,
1314} from "lucide-react" ;
1415import Link from "next/link" ;
1516import { toast } from "sonner" ;
@@ -37,6 +38,7 @@ import {
3738 getAddressBookEntries ,
3839 touchAddressBookEntry ,
3940} from "@/lib/address-book" ;
41+ import { isFederationAddress , resolveFederationAddress } from "@/lib/federation" ;
4042import {
4143 buildNextRunAt ,
4244 saveRecurringRule ,
@@ -197,6 +199,71 @@ export function CreateForm() {
197199 } ;
198200 } ) ;
199201
202+ // Issue #155: Federation address (name*domain.com) support for the
203+ // recipient field. `recipientInput` is exactly what the user typed
204+ // (either a raw G-address or a Federation address); `form.recipient`
205+ // always holds the resolved G-address actually used for the transaction.
206+ const [ recipientInput , setRecipientInput ] = useState ( form . recipient ) ;
207+ const [ federationStatus , setFederationStatus ] = useState <
208+ "idle" | "loading" | "resolved" | "error"
209+ > ( "idle" ) ;
210+ const [ federationError , setFederationError ] = useState < string | null > ( null ) ;
211+ const [ federationResolved , setFederationResolved ] = useState < {
212+ federationAddress : string ;
213+ accountId : string ;
214+ } | null > ( null ) ;
215+
216+ useEffect ( ( ) => {
217+ const raw = recipientInput . trim ( ) ;
218+
219+ if ( ! raw ) {
220+ setFederationStatus ( "idle" ) ;
221+ setFederationError ( null ) ;
222+ setFederationResolved ( null ) ;
223+ set ( "recipient" , "" ) ;
224+ return ;
225+ }
226+
227+ if ( ! isFederationAddress ( raw ) ) {
228+ setFederationStatus ( "idle" ) ;
229+ setFederationError ( null ) ;
230+ setFederationResolved ( null ) ;
231+ set ( "recipient" , raw ) ;
232+ return ;
233+ }
234+
235+ setFederationStatus ( "loading" ) ;
236+ setFederationError ( null ) ;
237+ let cancelled = false ;
238+ const timer = setTimeout ( ( ) => {
239+ resolveFederationAddress ( raw )
240+ . then ( ( result ) => {
241+ if ( cancelled ) return ;
242+ setFederationResolved ( {
243+ federationAddress : result . federationAddress ,
244+ accountId : result . accountId ,
245+ } ) ;
246+ setFederationStatus ( "resolved" ) ;
247+ set ( "recipient" , result . accountId ) ;
248+ } )
249+ . catch ( ( err ) => {
250+ if ( cancelled ) return ;
251+ setFederationResolved ( null ) ;
252+ setFederationStatus ( "error" ) ;
253+ setFederationError (
254+ err instanceof Error ? err . message : "Federation lookup failed" ,
255+ ) ;
256+ set ( "recipient" , "" ) ;
257+ } ) ;
258+ } , 500 ) ;
259+
260+ return ( ) => {
261+ cancelled = true ;
262+ clearTimeout ( timer ) ;
263+ } ;
264+ // eslint-disable-next-line react-hooks/exhaustive-deps
265+ } , [ recipientInput ] ) ;
266+
200267 const [ errors , setErrors ] = useState <
201268 Partial < Record < keyof FormState , string > >
202269 > ( { } ) ;
@@ -301,6 +368,7 @@ export function CreateForm() {
301368 form ,
302369 ( draft ) => {
303370 setForm ( draft ) ;
371+ setRecipientInput ( draft . recipient ) ;
304372 } ,
305373 true ,
306374 ) ;
@@ -355,12 +423,19 @@ export function CreateForm() {
355423 function validate ( ) : boolean {
356424 const newErrors : Partial < Record < keyof FormState , string > > = { } ;
357425
358- // Issue #28: use StrKey for proper Stellar address validation
359- if (
426+ // Issue #155: block submission while a Federation address is still resolving
427+ if ( federationStatus === "loading" ) {
428+ newErrors . recipient = "Still resolving the Federation address…" ;
429+ } else if ( federationStatus === "error" ) {
430+ newErrors . recipient = federationError ?? "Could not resolve Federation address" ;
431+ } else if (
432+ // Issue #28: use StrKey for proper Stellar address validation
360433 ! form . recipient . trim ( ) ||
361434 ! StrKey . isValidEd25519PublicKey ( form . recipient . trim ( ) )
362435 ) {
363- newErrors . recipient = "Invalid Stellar address format" ;
436+ newErrors . recipient = isFederationAddress ( recipientInput . trim ( ) )
437+ ? "Federation address did not resolve to a valid Stellar account"
438+ : "Invalid Stellar address format" ;
364439 }
365440 // Issue #103: require warning acknowledgment for unfunded accounts
366441 if (
@@ -808,15 +883,40 @@ export function CreateForm() {
808883 Recipient
809884 </ h2 >
810885 < div className = "space-y-1.5" >
811- < Label htmlFor = "recipient" > Stellar address</ Label >
812- < Input
813- id = "recipient"
814- placeholder = "GABC…"
815- value = { form . recipient }
816- onChange = { ( e ) => set ( "recipient" , e . target . value ) }
817- aria-invalid = { ! ! errors . recipient }
818- className = "font-mono text-xs"
819- />
886+ < Label htmlFor = "recipient" > Stellar address or Federation name</ Label >
887+ < div className = "relative" >
888+ < Input
889+ id = "recipient"
890+ placeholder = "GABC… or alice*domain.com"
891+ value = { recipientInput }
892+ onChange = { ( e ) => setRecipientInput ( e . target . value ) }
893+ aria-invalid = { ! ! errors . recipient }
894+ className = "font-mono text-xs pr-8"
895+ />
896+ { federationStatus === "loading" && (
897+ < Loader2 className = "absolute right-2.5 top-1/2 size-3.5 -translate-y-1/2 animate-spin text-muted-foreground" />
898+ ) }
899+ { federationStatus === "resolved" && (
900+ < CheckCircle2 className = "absolute right-2.5 top-1/2 size-3.5 -translate-y-1/2 text-emerald-500" />
901+ ) }
902+ </ div >
903+ { /* Issue #155: Federation address (name*domain.com) resolution */ }
904+ { federationStatus === "resolved" && federationResolved && (
905+ < div className = "flex items-start gap-2 rounded-lg border border-emerald-500/30 bg-emerald-500/10 p-3 text-sm text-emerald-700 dark:text-emerald-400" >
906+ < CheckCircle2 className = "mt-0.5 size-4 shrink-0" />
907+ < div >
908+ < p className = "font-medium" >
909+ { federationResolved . federationAddress } resolved
910+ </ p >
911+ < p className = "font-mono text-xs opacity-80 break-all" >
912+ { federationResolved . accountId }
913+ </ p >
914+ </ div >
915+ </ div >
916+ ) }
917+ { federationStatus === "error" && federationError && (
918+ < p className = "text-xs text-destructive" > { federationError } </ p >
919+ ) }
820920 < div className = "flex flex-wrap gap-2" >
821921 < Button
822922 type = "button"
@@ -827,8 +927,15 @@ export function CreateForm() {
827927 if ( ! trimmed || ! StrKey . isValidEd25519PublicKey ( trimmed ) )
828928 return ;
829929 addAddressBookEntry ( {
830- label : "Saved recipient" ,
930+ label :
931+ federationResolved ?. accountId === trimmed
932+ ? federationResolved . federationAddress
933+ : "Saved recipient" ,
831934 address : trimmed ,
935+ federationAddress :
936+ federationResolved ?. accountId === trimmed
937+ ? federationResolved . federationAddress
938+ : undefined ,
832939 } ) ;
833940 setAddressBookEntries ( getAddressBookEntries ( ) ) ;
834941 toast . success ( "Recipient saved" ) ;
@@ -851,11 +958,15 @@ export function CreateForm() {
851958 < button
852959 key = { entry . id }
853960 type = "button"
854- onClick = { ( ) => set ( "recipient" , entry . address ) }
961+ onClick = { ( ) => {
962+ setRecipientInput ( entry . address ) ;
963+ set ( "recipient" , entry . address ) ;
964+ } }
855965 className = "rounded-full border border-border bg-background px-3 py-1 text-left text-xs text-muted-foreground transition-colors hover:border-primary hover:text-primary"
966+ title = { entry . address }
856967 >
857968 < span className = "font-medium text-foreground" >
858- { entry . label }
969+ { entry . federationAddress ?? entry . label }
859970 </ span > { " " }
860971 • { entry . address . slice ( 0 , 8 ) } …
861972 </ button >
0 commit comments