@@ -1066,4 +1066,196 @@ describe('WalletCreationOrchestrator', () => {
10661066 expect ( ageMs ) . toBeLessThan ( 6 * 60 * 1000 ) ;
10671067 } ) ;
10681068 } ) ;
1069+
1070+ // -------------------------------------------------------------------------
1071+ // Retry with backoff integration (#418)
1072+ // -------------------------------------------------------------------------
1073+
1074+ describe ( 'retry with backoff' , ( ) => {
1075+ const transientError = Object . assign ( new Error ( 'connection reset' ) , {
1076+ code : 'ECONNRESET' ,
1077+ } ) ;
1078+
1079+ const provisioningWallet = {
1080+ id : 'wallet-123' ,
1081+ userId : 'user-123' ,
1082+ publicKey : 'GABC123DEF456' ,
1083+ encryptedSecret : 'encrypted-private-key' ,
1084+ encryptionVersion : 1 ,
1085+ secretVersion : 1 ,
1086+ network : WalletNetwork . TESTNET ,
1087+ status : 'PROVISIONING' ,
1088+ statusReason : null ,
1089+ statusChangedAt : new Date ( ) ,
1090+ rotatedFromId : null ,
1091+ createdAt : new Date ( ) ,
1092+ updatedAt : new Date ( ) ,
1093+ } ;
1094+ const activeWallet = {
1095+ ...provisioningWallet ,
1096+ status : 'ACTIVE' ,
1097+ statusReason : 'Wallet provisioned and activated' ,
1098+ } ;
1099+
1100+ /** Creates an orchestrator wired with a real WalletRetryService (wait stubbed). */
1101+ function makeOrchestratorWithRetry ( ) {
1102+ const { WalletRetryService } = jest . requireActual ( './wallet-retry.service' ) as typeof import ( './wallet-retry.service' ) ;
1103+ const retryConfig = { get : ( _k : string , fallback : number ) => fallback } ;
1104+ const retryService = new WalletRetryService ( retryConfig as any ) ;
1105+ // Stub internal wait so tests run instantly
1106+ jest . spyOn ( retryService as any , 'wait' ) . mockResolvedValue ( undefined ) ;
1107+
1108+ return {
1109+ retryService,
1110+ orchestrator : new WalletCreationOrchestrator (
1111+ mockEncryptionService as any ,
1112+ mockConfigService as any ,
1113+ mockIdempotentUserService as any ,
1114+ mockKeyManagementService as any ,
1115+ mockPrisma as any ,
1116+ undefined ,
1117+ retryService ,
1118+ ) ,
1119+ } ;
1120+ }
1121+
1122+ it ( 'retries key generation on transient failures and eventually succeeds' , async ( ) => {
1123+ const { orchestrator : retryOrchestrator } = makeOrchestratorWithRetry ( ) ;
1124+
1125+ mockPrisma . $transaction . mockImplementation ( async ( cb : any ) =>
1126+ cb ( mockPrisma ) ,
1127+ ) ;
1128+ mockPrisma . wallet . findFirst . mockResolvedValue ( null ) ;
1129+ mockPrisma . wallet . create . mockResolvedValue ( provisioningWallet ) ;
1130+ mockPrisma . wallet . update . mockResolvedValue ( activeWallet ) ;
1131+
1132+ // Key generation fails twice (transient), succeeds on the third attempt
1133+ mockKeyManagementService . generateKey
1134+ . mockRejectedValueOnce ( transientError )
1135+ . mockRejectedValueOnce ( transientError )
1136+ . mockResolvedValueOnce ( {
1137+ publicKey : 'GABC123DEF456' ,
1138+ encryptedData : 'encrypted-private-key' ,
1139+ encryptionVersion : 1 ,
1140+ } ) ;
1141+
1142+ const result = await retryOrchestrator . createWallet ( {
1143+ userId : 'user-123' ,
1144+ network : WalletNetwork . TESTNET ,
1145+ } ) ;
1146+
1147+ expect ( result . isNewWallet ) . toBe ( true ) ;
1148+ expect ( mockKeyManagementService . generateKey ) . toHaveBeenCalledTimes ( 3 ) ;
1149+ } ) ;
1150+
1151+ it ( 'throws WalletOrchestrationError(key-generation) when key generation exhausts all retries' , async ( ) => {
1152+ const { orchestrator : retryOrchestrator } = makeOrchestratorWithRetry ( ) ;
1153+
1154+ mockPrisma . $transaction . mockImplementation ( async ( cb : any ) =>
1155+ cb ( mockPrisma ) ,
1156+ ) ;
1157+ mockPrisma . wallet . findFirst . mockResolvedValue ( null ) ;
1158+
1159+ mockKeyManagementService . generateKey . mockRejectedValue ( transientError ) ;
1160+
1161+ const err = await retryOrchestrator
1162+ . createWallet ( { userId : 'user-123' , network : WalletNetwork . TESTNET } )
1163+ . catch ( ( e ) => e ) ;
1164+
1165+ expect ( err ) . toBeInstanceOf ( WalletOrchestrationError ) ;
1166+ expect ( err . phase ) . toBe ( 'key-generation' ) ;
1167+ // generateKey called maxAttempts (3) times
1168+ expect ( mockKeyManagementService . generateKey ) . toHaveBeenCalledTimes ( 3 ) ;
1169+ } ) ;
1170+
1171+ it ( 'does not retry non-transient key generation failures (400)' , async ( ) => {
1172+ const { orchestrator : retryOrchestrator } = makeOrchestratorWithRetry ( ) ;
1173+
1174+ mockPrisma . $transaction . mockImplementation ( async ( cb : any ) =>
1175+ cb ( mockPrisma ) ,
1176+ ) ;
1177+ mockPrisma . wallet . findFirst . mockResolvedValue ( null ) ;
1178+
1179+ const nonTransient = Object . assign ( new Error ( 'bad request' ) , {
1180+ status : 400 ,
1181+ } ) ;
1182+ mockKeyManagementService . generateKey . mockRejectedValue ( nonTransient ) ;
1183+
1184+ const err = await retryOrchestrator
1185+ . createWallet ( { userId : 'user-123' , network : WalletNetwork . TESTNET } )
1186+ . catch ( ( e ) => e ) ;
1187+
1188+ expect ( err ) . toBeInstanceOf ( WalletOrchestrationError ) ;
1189+ expect ( err . phase ) . toBe ( 'key-generation' ) ;
1190+ // No retries — called exactly once
1191+ expect ( mockKeyManagementService . generateKey ) . toHaveBeenCalledTimes ( 1 ) ;
1192+ } ) ;
1193+
1194+ it ( 'retries Friendbot on transient HTTP failures and wallet creation still succeeds' , async ( ) => {
1195+ const { orchestrator : retryOrchestrator } = makeOrchestratorWithRetry ( ) ;
1196+
1197+ mockPrisma . $transaction . mockImplementation ( async ( cb : any ) =>
1198+ cb ( mockPrisma ) ,
1199+ ) ;
1200+ mockPrisma . wallet . findFirst . mockResolvedValue ( null ) ;
1201+ mockPrisma . wallet . create . mockResolvedValue ( provisioningWallet ) ;
1202+ mockPrisma . wallet . update . mockResolvedValue ( activeWallet ) ;
1203+
1204+ // Friendbot responds with 503 twice, then succeeds
1205+ mockFetch
1206+ . mockResolvedValueOnce ( { ok : false , status : 503 , text : async ( ) => 'unavailable' } )
1207+ . mockResolvedValueOnce ( { ok : false , status : 503 , text : async ( ) => 'unavailable' } )
1208+ . mockResolvedValueOnce ( { ok : true } ) ;
1209+
1210+ const result = await retryOrchestrator . createWallet ( {
1211+ userId : 'user-123' ,
1212+ network : WalletNetwork . TESTNET ,
1213+ } ) ;
1214+
1215+ expect ( result . isNewWallet ) . toBe ( true ) ;
1216+ expect ( result . wallet . status ) . toBe ( WalletStatus . ACTIVE ) ;
1217+ expect ( mockFetch ) . toHaveBeenCalledTimes ( 3 ) ;
1218+ } ) ;
1219+
1220+ it ( 'wallet creation completes even when Friendbot exhausts all retries' , async ( ) => {
1221+ const { orchestrator : retryOrchestrator } = makeOrchestratorWithRetry ( ) ;
1222+
1223+ mockPrisma . $transaction . mockImplementation ( async ( cb : any ) =>
1224+ cb ( mockPrisma ) ,
1225+ ) ;
1226+ mockPrisma . wallet . findFirst . mockResolvedValue ( null ) ;
1227+ mockPrisma . wallet . create . mockResolvedValue ( provisioningWallet ) ;
1228+ mockPrisma . wallet . update . mockResolvedValue ( activeWallet ) ;
1229+
1230+ // Friendbot always returns 503
1231+ mockFetch . mockResolvedValue ( { ok : false , status : 503 , text : async ( ) => 'down' } ) ;
1232+
1233+ const result = await retryOrchestrator . createWallet ( {
1234+ userId : 'user-123' ,
1235+ network : WalletNetwork . TESTNET ,
1236+ } ) ;
1237+
1238+ // Wallet is still created and activated — Friendbot is non-blocking
1239+ expect ( result . isNewWallet ) . toBe ( true ) ;
1240+ expect ( result . wallet . status ) . toBe ( WalletStatus . ACTIVE ) ;
1241+ } ) ;
1242+
1243+ it ( 'falls back to direct key generation when retry service is absent' , async ( ) => {
1244+ // Default orchestrator created in beforeEach has no retry service
1245+ mockPrisma . $transaction . mockImplementation ( async ( cb : any ) =>
1246+ cb ( mockPrisma ) ,
1247+ ) ;
1248+ mockPrisma . wallet . findFirst . mockResolvedValue ( null ) ;
1249+ mockPrisma . wallet . create . mockResolvedValue ( provisioningWallet ) ;
1250+ mockPrisma . wallet . update . mockResolvedValue ( activeWallet ) ;
1251+
1252+ const result = await orchestrator . createWallet ( {
1253+ userId : 'user-123' ,
1254+ network : WalletNetwork . TESTNET ,
1255+ } ) ;
1256+
1257+ expect ( result . isNewWallet ) . toBe ( true ) ;
1258+ expect ( mockKeyManagementService . generateKey ) . toHaveBeenCalledTimes ( 1 ) ;
1259+ } ) ;
1260+ } ) ;
10691261} ) ;
0 commit comments