From 147c65b7abb30bdc6026bb52aa62ee8084272a22 Mon Sep 17 00:00:00 2001 From: Amir Rajabi Date: Tue, 27 Oct 2026 15:40:01 +0330 Subject: [PATCH] Add admin APIs for address assignment management --- .../opex/api/core/inout/AddressAssignment.kt | 69 ++++++ .../api/core/spi/BlockchainGatewayProxy.kt | 13 + .../opex/controller/AddressAdminController.kt | 222 ++++++++++++++++++ .../proxy/impl/BlockchainGatewayProxyImpl.kt | 64 +++++ .../opex/bcgateway/app/config/AppConfig.kt | 11 + .../app/controller/AddressAdminController.kt | 46 ++++ .../bcgateway/core/api/AddressAdminService.kt | 21 ++ .../bcgateway/core/model/AddressAssignment.kt | 59 +++++ .../core/service/AddressAdminServiceImpl.kt | 109 +++++++++ .../core/service/AssignAddressServiceImpl.kt | 3 +- .../core/spi/AssignedAddressHandler.kt | 12 + .../core/spi/ReservedAddressHandler.kt | 31 ++- .../AssignAddressServiceImplUnitTest.kt | 11 +- .../postgres/dao/AssignedAddressRepository.kt | 54 +++++ .../postgres/dao/ReservedAddressRepository.kt | 95 +++++++- .../impl/AssignedAddressHandlerImpl.kt | 82 +++++++ .../impl/ReservedAddressHandlerImpl.kt | 48 +++- .../ports/postgres/util/Convertor.kt | 17 +- .../kotlin/co/nilin/opex/common/OpexError.kt | 5 + 19 files changed, 948 insertions(+), 24 deletions(-) create mode 100644 api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/AddressAssignment.kt create mode 100644 api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/AddressAdminController.kt create mode 100644 bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/controller/AddressAdminController.kt create mode 100644 bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/api/AddressAdminService.kt create mode 100644 bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/AddressAssignment.kt create mode 100644 bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/service/AddressAdminServiceImpl.kt diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/AddressAssignment.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/AddressAssignment.kt new file mode 100644 index 000000000..2c83ddc3d --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/AddressAssignment.kt @@ -0,0 +1,69 @@ +package co.nilin.opex.api.core.inout + +import java.time.LocalDateTime + +data class AddressAssignmentFilter( + val uuid: String? = null, + val address: String? = null, + val memo: String? = null, + val status: AddressAssignmentStatus? = null, + val addressTypeId: Long? = null, + val chain: String? = null, + val assignedFrom: Long? = null, + val assignedTo: Long? = null, + val limit: Int = 20, + val offset: Int = 0 +) + +enum class AddressAssignmentStatus { + Assigned, Revoked, Reserved +} + +data class AddressTypeInfo( + val id: Long, + val type: String +) + +data class AddressAssignmentRecord( + val id: Long, + val uuid: String, + val address: String, + val memo: String?, + val addressType: AddressTypeInfo?, + val assignedDate: LocalDateTime?, + val expTime: LocalDateTime?, + val revokedDate: LocalDateTime?, + val status: AddressAssignmentStatus?, + val fullName: String? = null +) + +data class AddressAssignmentPage( + val total: Long, + val items: List +) + +data class AdminAssignAddressRequest( + val uuid: String, + val address: String? = null, + val memo: String? = null, + val expTime: Long? = null +) + +data class ReservedAddressFilter( + val addressTypeId: Long? = null, + val chain: String? = null, + val address: String? = null, + val limit: Int = 20, + val offset: Int = 0 +) + +data class ReservedAddressRecord( + val address: String, + val memo: String?, + val addressType: AddressTypeInfo? +) + +data class ReservedAddressPage( + val total: Long, + val items: List +) diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/BlockchainGatewayProxy.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/BlockchainGatewayProxy.kt index 2929c5f96..17a523af4 100644 --- a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/BlockchainGatewayProxy.kt +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/BlockchainGatewayProxy.kt @@ -1,11 +1,17 @@ package co.nilin.opex.api.core.spi +import co.nilin.opex.api.core.inout.AddressAssignmentFilter +import co.nilin.opex.api.core.inout.AddressAssignmentPage +import co.nilin.opex.api.core.inout.AddressAssignmentRecord +import co.nilin.opex.api.core.inout.AdminAssignAddressRequest import co.nilin.opex.api.core.inout.AssignAddressRequest import co.nilin.opex.api.core.inout.AssignResponse import co.nilin.opex.api.core.inout.ChainInfo import co.nilin.opex.api.core.inout.DepositDetails import co.nilin.opex.api.core.inout.GatewayLocalizationCommand import co.nilin.opex.api.core.inout.GatewayLocalizationResponse +import co.nilin.opex.api.core.inout.ReservedAddressFilter +import co.nilin.opex.api.core.inout.ReservedAddressPage interface BlockchainGatewayProxy { @@ -21,4 +27,11 @@ interface BlockchainGatewayProxy { ): GatewayLocalizationResponse suspend fun deleteOnChainGatewayLocalization(token: String, id: Long) + + // Admin address assignment reports and management + suspend fun searchAddressAssignments(token: String, filter: AddressAssignmentFilter): AddressAssignmentPage + suspend fun getAddressAssignment(token: String, id: Long): AddressAssignmentRecord + suspend fun searchReservedAddresses(token: String, filter: ReservedAddressFilter): ReservedAddressPage + suspend fun assignAddressByAdmin(token: String, request: AdminAssignAddressRequest): AddressAssignmentRecord + suspend fun revokeAddressAssignment(token: String, id: Long): AddressAssignmentRecord } \ No newline at end of file diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/AddressAdminController.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/AddressAdminController.kt new file mode 100644 index 000000000..1ee737ae3 --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/AddressAdminController.kt @@ -0,0 +1,222 @@ +package co.nilin.opex.api.ports.opex.controller + +import co.nilin.opex.api.core.inout.AddressAssignmentFilter +import co.nilin.opex.api.core.inout.AddressAssignmentPage +import co.nilin.opex.api.core.inout.AddressAssignmentRecord +import co.nilin.opex.api.core.inout.AdminAssignAddressRequest +import co.nilin.opex.api.core.inout.ReservedAddressFilter +import co.nilin.opex.api.core.inout.ReservedAddressPage +import co.nilin.opex.api.core.spi.BlockchainGatewayProxy +import co.nilin.opex.api.ports.opex.service.OwnerNameResolver +import co.nilin.opex.api.ports.opex.util.jwtAuthentication +import co.nilin.opex.api.ports.opex.util.tokenValue +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.http.MediaType +import org.springframework.security.core.annotation.CurrentSecurityContext +import org.springframework.security.core.context.SecurityContext +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/opex/v1/admin/address") +@Tag(name = "Address Admin", description = "Admin reports and management of blockchain address assignments.") +class AddressAdminController( + private val blockchainGatewayProxy: BlockchainGatewayProxy, + private val ownerNameResolver: OwnerNameResolver +) { + + @PostMapping("/assignments/search", produces = [MediaType.APPLICATION_JSON_VALUE]) + @Operation( + summary = "Search address assignments", + description = """POST /opex/v1/admin/address/assignments/search. +Security: Bearer admin-token required. Required authority: ROLE_admin. +Behavior: Every assignment is a separate record, so filtering by `uuid` returns the address history of a user and filtering by `address` returns the holder history of that address. Ordered by assignment date, newest first. +Filters: `uuid`, `address`, `memo`, `status`, `addressTypeId`, `chain`, `assignedFrom` and `assignedTo` (epoch millis), plus `limit` and `offset`. Every filter is optional. +Allowed values: +- status: Assigned, Revoked, Reserved.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content(mediaType = "application/json", schema = Schema(implementation = AddressAssignmentPage::class))] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "403", + description = "Forbidden. Required authority is missing: ROLE_admin. No response body.", + content = [Content()] + ) + ] + ) + suspend fun search( + @RequestBody filter: AddressAssignmentFilter, + @Parameter(hidden = true) + @CurrentSecurityContext securityContext: SecurityContext + ): AddressAssignmentPage { + val page = blockchainGatewayProxy.searchAddressAssignments( + securityContext.jwtAuthentication().tokenValue(), + filter + ) + return page.copy(items = page.items.withNames()) + } + + @GetMapping("/assignments/{id}", produces = [MediaType.APPLICATION_JSON_VALUE]) + @Operation( + summary = "Get address assignment", + description = """GET /opex/v1/admin/address/assignments/{id}. +Security: Bearer admin-token required. Required authority: ROLE_admin. +Behavior: Returns a single assignment record with the holder name.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content(mediaType = "application/json", schema = Schema(implementation = AddressAssignmentRecord::class))] + ), + ApiResponse(responseCode = "404", description = "No assignment with this id.", content = [Content()]) + ] + ) + suspend fun getAssignment( + @Parameter(name = "id", description = "Assignment id.", required = true) + @PathVariable("id") id: Long, + @Parameter(hidden = true) + @CurrentSecurityContext securityContext: SecurityContext + ): AddressAssignmentRecord { + return blockchainGatewayProxy + .getAddressAssignment(securityContext.jwtAuthentication().tokenValue(), id) + .withName() + } + + @PostMapping("/reserved/search", produces = [MediaType.APPLICATION_JSON_VALUE]) + @Operation( + summary = "List available addresses", + description = """POST /opex/v1/admin/address/reserved/search. +Security: Bearer admin-token required. Required authority: ROLE_admin. +Behavior: Returns the addresses that are free right now, which are the only ones `assign` accepts. Call this first, then pass one of the returned `address` plus `memo` pairs to `assign`. +Filters: `addressTypeId`, `chain`, `address`, plus `limit` and `offset`. Every filter is optional. +Note: The list is a snapshot. An address can be taken by another request before it is assigned, in which case `assign` answers 400.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content(mediaType = "application/json", schema = Schema(implementation = ReservedAddressPage::class))] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ), + ApiResponse( + responseCode = "403", + description = "Forbidden. Required authority is missing: ROLE_admin. No response body.", + content = [Content()] + ) + ] + ) + suspend fun listAvailable( + @RequestBody filter: ReservedAddressFilter, + @Parameter(hidden = true) + @CurrentSecurityContext securityContext: SecurityContext + ): ReservedAddressPage { + return blockchainGatewayProxy.searchReservedAddresses( + securityContext.jwtAuthentication().tokenValue(), + filter + ) + } + + @PostMapping("/assign", produces = [MediaType.APPLICATION_JSON_VALUE]) + @Operation( + summary = "Assign an address to a user", + description = """POST /opex/v1/admin/address/assign. +Security: Bearer admin-token required. Required authority: ROLE_admin. +Validation: `uuid` is required. Everything else is optional. +Behavior: Assigns an address from the reserved pool to the user. Either leave `address` out and let the pool pick one, or pass an `address` and `memo` taken from `/reserved/search`. An address outside the pool is never accepted. `expTime` is epoch millis and falls back to the configured address life time. +Address type and chain are not request fields: they belong to the address, so they are taken from the claimed pool entry. To assign on a specific chain, filter `/reserved/search` by `chain` and pass one of its addresses. +Concurrency: The address is taken out of the pool in the same statement that reads it, so two admins cannot assign the same address. Whoever loses the race gets 400 or 409, never a shared address.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content(mediaType = "application/json", schema = Schema(implementation = AddressAssignmentRecord::class))] + ), + ApiResponse( + responseCode = "400", + description = "uuid is missing, the requested address is not in the reserved pool or was just taken, or no address is available.", + content = [Content()] + ), + ApiResponse( + responseCode = "409", + description = "The address is already assigned to someone else.", + content = [Content()] + ) + ] + ) + suspend fun assign( + @RequestBody request: AdminAssignAddressRequest, + @Parameter(hidden = true) + @CurrentSecurityContext securityContext: SecurityContext + ): AddressAssignmentRecord { + return blockchainGatewayProxy + .assignAddressByAdmin(securityContext.jwtAuthentication().tokenValue(), request) + .withName() + } + + @PostMapping("/assignments/{id}/revoke", produces = [MediaType.APPLICATION_JSON_VALUE]) + @Operation( + summary = "Revoke an address assignment", + description = """POST /opex/v1/admin/address/assignments/{id}/revoke. +Security: Bearer admin-token required. Required authority: ROLE_admin. +Behavior: Closes an active assignment. The record stays in the history with its revoke date. Pick the `id` from the search results of the user.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content(mediaType = "application/json", schema = Schema(implementation = AddressAssignmentRecord::class))] + ), + ApiResponse( + responseCode = "400", + description = "The assignment is not active.", + content = [Content()] + ), + ApiResponse(responseCode = "404", description = "No assignment with this id.", content = [Content()]) + ] + ) + suspend fun revoke( + @Parameter(name = "id", description = "Assignment id.", required = true) + @PathVariable("id") id: Long, + @Parameter(hidden = true) + @CurrentSecurityContext securityContext: SecurityContext + ): AddressAssignmentRecord { + return blockchainGatewayProxy + .revokeAddressAssignment(securityContext.jwtAuthentication().tokenValue(), id) + .withName() + } + + private suspend fun List.withNames(): List { + if (isEmpty()) return this + val names = ownerNameResolver.resolve(map { it.uuid }.toSet()) + return map { it.copy(fullName = names[it.uuid]) } + } + + private suspend fun AddressAssignmentRecord.withName(): AddressAssignmentRecord { + return copy(fullName = ownerNameResolver.resolve(setOf(uuid))[uuid]) + } +} diff --git a/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/BlockchainGatewayProxyImpl.kt b/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/BlockchainGatewayProxyImpl.kt index 5cecfadaa..7825bccdd 100644 --- a/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/BlockchainGatewayProxyImpl.kt +++ b/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/BlockchainGatewayProxyImpl.kt @@ -102,6 +102,70 @@ class BlockchainGatewayProxyImpl(@Qualifier("generalWebClient") private val clie .awaitFirstOrElse { throw OpexError.BadRequest.exception() } } + override suspend fun searchAddressAssignments( + token: String, + filter: AddressAssignmentFilter + ): AddressAssignmentPage { + return client.post() + .uri("$baseUrl/admin/address/assignments/search") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .body(Mono.just(filter)) + .retrieve() + .onStatus({ it.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to search address assignments") } + } + + override suspend fun getAddressAssignment(token: String, id: Long): AddressAssignmentRecord { + return client.get() + .uri("$baseUrl/admin/address/assignments/$id") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .onStatus({ it.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to get address assignment $id") } + } + + override suspend fun searchReservedAddresses(token: String, filter: ReservedAddressFilter): ReservedAddressPage { + return client.post() + .uri("$baseUrl/admin/address/reserved/search") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .body(Mono.just(filter)) + .retrieve() + .onStatus({ it.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to search reserved addresses") } + } + + override suspend fun assignAddressByAdmin( + token: String, + request: AdminAssignAddressRequest + ): AddressAssignmentRecord { + return client.post() + .uri("$baseUrl/admin/address/assign") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .body(Mono.just(request)) + .retrieve() + .onStatus({ it.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to assign address") } + } + + override suspend fun revokeAddressAssignment(token: String, id: Long): AddressAssignmentRecord { + return client.post() + .uri("$baseUrl/admin/address/assignments/$id/revoke") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .onStatus({ it.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to revoke address assignment $id") } + } + override suspend fun deleteOnChainGatewayLocalization(token: String, id: Long) { client.delete() .uri("$baseUrl/crypto-currency/gateway/localization/${id}") diff --git a/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/config/AppConfig.kt b/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/config/AppConfig.kt index 6c326f929..cf6bd4a42 100644 --- a/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/config/AppConfig.kt +++ b/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/config/AppConfig.kt @@ -1,10 +1,13 @@ package co.nilin.opex.bcgateway.app.config +import co.nilin.opex.bcgateway.core.api.AddressAdminService import co.nilin.opex.bcgateway.core.api.AssignAddressService import co.nilin.opex.bcgateway.core.api.InfoService +import co.nilin.opex.bcgateway.core.service.AddressAdminServiceImpl import co.nilin.opex.bcgateway.core.service.AssignAddressServiceImpl import co.nilin.opex.bcgateway.core.service.InfoServiceImpl +import co.nilin.opex.bcgateway.core.spi.AddressTypeHandler import co.nilin.opex.bcgateway.core.spi.AssignedAddressHandler import co.nilin.opex.bcgateway.core.spi.ChainLoader import co.nilin.opex.bcgateway.core.spi.ReservedAddressHandler @@ -35,6 +38,14 @@ class AppConfig(private val resourceLoader: ResourceLoader) { return AssignAddressServiceImpl(currencyHandler, assignedAddressHandler, reservedAddressHandler, chainLoader) } + @Bean + fun addressAdminService( + assignedAddressHandler: AssignedAddressHandler, + reservedAddressHandler: ReservedAddressHandler + ): AddressAdminService { + return AddressAdminServiceImpl(assignedAddressHandler, reservedAddressHandler) + } + @Bean fun infoService(): InfoService { return InfoServiceImpl() diff --git a/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/controller/AddressAdminController.kt b/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/controller/AddressAdminController.kt new file mode 100644 index 000000000..0cf2d7068 --- /dev/null +++ b/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/controller/AddressAdminController.kt @@ -0,0 +1,46 @@ +package co.nilin.opex.bcgateway.app.controller + +import co.nilin.opex.bcgateway.core.api.AddressAdminService +import co.nilin.opex.bcgateway.core.model.AddressAssignmentFilter +import co.nilin.opex.bcgateway.core.model.AddressAssignmentPage +import co.nilin.opex.bcgateway.core.model.AddressAssignmentRecord +import co.nilin.opex.bcgateway.core.model.AdminAssignAddressRequest +import co.nilin.opex.bcgateway.core.model.ReservedAddressFilter +import co.nilin.opex.bcgateway.core.model.ReservedAddressPage +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/admin/address") +class AddressAdminController(private val addressAdminService: AddressAdminService) { + + + @PostMapping("/assignments/search") + suspend fun search(@RequestBody filter: AddressAssignmentFilter): AddressAssignmentPage { + return addressAdminService.search(filter) + } + + @GetMapping("/assignments/{id}") + suspend fun getAssignment(@PathVariable("id") id: Long): AddressAssignmentRecord { + return addressAdminService.findById(id) + } + + @PostMapping("/reserved/search") + suspend fun listAvailable(@RequestBody filter: ReservedAddressFilter): ReservedAddressPage { + return addressAdminService.listAvailableAddresses(filter) + } + + @PostMapping("/assign") + suspend fun assign(@RequestBody request: AdminAssignAddressRequest): AddressAssignmentRecord { + return addressAdminService.assign(request) + } + + @PostMapping("/assignments/{id}/revoke") + suspend fun revoke(@PathVariable("id") id: Long): AddressAssignmentRecord { + return addressAdminService.revoke(id) + } +} diff --git a/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/api/AddressAdminService.kt b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/api/AddressAdminService.kt new file mode 100644 index 000000000..244c96759 --- /dev/null +++ b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/api/AddressAdminService.kt @@ -0,0 +1,21 @@ +package co.nilin.opex.bcgateway.core.api + +import co.nilin.opex.bcgateway.core.model.AddressAssignmentFilter +import co.nilin.opex.bcgateway.core.model.AddressAssignmentPage +import co.nilin.opex.bcgateway.core.model.AddressAssignmentRecord +import co.nilin.opex.bcgateway.core.model.AdminAssignAddressRequest +import co.nilin.opex.bcgateway.core.model.ReservedAddressFilter +import co.nilin.opex.bcgateway.core.model.ReservedAddressPage + +interface AddressAdminService { + + suspend fun search(filter: AddressAssignmentFilter): AddressAssignmentPage + + suspend fun findById(id: Long): AddressAssignmentRecord + + suspend fun listAvailableAddresses(filter: ReservedAddressFilter): ReservedAddressPage + + suspend fun assign(request: AdminAssignAddressRequest): AddressAssignmentRecord + + suspend fun revoke(id: Long): AddressAssignmentRecord +} diff --git a/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/AddressAssignment.kt b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/AddressAssignment.kt new file mode 100644 index 000000000..34f48068b --- /dev/null +++ b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/AddressAssignment.kt @@ -0,0 +1,59 @@ +package co.nilin.opex.bcgateway.core.model + +import java.time.LocalDateTime + +data class AddressAssignmentFilter( + val uuid: String? = null, + val address: String? = null, + val memo: String? = null, + val status: AddressStatus? = null, + val addressTypeId: Long? = null, + val chain: String? = null, + val assignedFrom: Long? = null, + val assignedTo: Long? = null, + val limit: Int = 20, + val offset: Int = 0 +) + +data class AddressAssignmentRecord( + val id: Long, + val uuid: String, + val address: String, + val memo: String?, + val addressType: AddressType?, + val assignedDate: LocalDateTime?, + val expTime: LocalDateTime?, + val revokedDate: LocalDateTime?, + val status: AddressStatus? +) + +data class AddressAssignmentPage( + val total: Long, + val items: List +) + +data class AdminAssignAddressRequest( + val uuid: String, + val address: String? = null, + val memo: String? = null, + val expTime: Long? = null +) + +data class ReservedAddressFilter( + val addressTypeId: Long? = null, + val chain: String? = null, + val address: String? = null, + val limit: Int = 20, + val offset: Int = 0 +) + +data class ReservedAddressRecord( + val address: String, + val memo: String?, + val addressType: AddressType? +) + +data class ReservedAddressPage( + val total: Long, + val items: List +) diff --git a/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/service/AddressAdminServiceImpl.kt b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/service/AddressAdminServiceImpl.kt new file mode 100644 index 000000000..67a3698ac --- /dev/null +++ b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/service/AddressAdminServiceImpl.kt @@ -0,0 +1,109 @@ +package co.nilin.opex.bcgateway.core.service + +import co.nilin.opex.bcgateway.core.api.AddressAdminService +import co.nilin.opex.bcgateway.core.model.AddressAssignmentFilter +import co.nilin.opex.bcgateway.core.model.AddressAssignmentPage +import co.nilin.opex.bcgateway.core.model.AddressAssignmentRecord +import co.nilin.opex.bcgateway.core.model.AddressStatus +import co.nilin.opex.bcgateway.core.model.AdminAssignAddressRequest +import co.nilin.opex.bcgateway.core.model.AssignedAddress +import co.nilin.opex.bcgateway.core.model.ReservedAddress +import co.nilin.opex.bcgateway.core.model.ReservedAddressFilter +import co.nilin.opex.bcgateway.core.model.ReservedAddressPage +import co.nilin.opex.bcgateway.core.spi.AssignedAddressHandler +import co.nilin.opex.bcgateway.core.spi.ReservedAddressHandler +import co.nilin.opex.bcgateway.core.utils.LoggerDelegate +import co.nilin.opex.common.OpexError +import org.slf4j.Logger +import org.springframework.beans.factory.annotation.Value +import org.springframework.transaction.annotation.Transactional +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId + +open class AddressAdminServiceImpl( + private val assignedAddressHandler: AssignedAddressHandler, + private val reservedAddressHandler: ReservedAddressHandler +) : AddressAdminService { + + @Value("\${app.address.life-time}") + private var addressLifeTime: Long? = null + + private val logger: Logger by LoggerDelegate() + + override suspend fun search(filter: AddressAssignmentFilter): AddressAssignmentPage { + return AddressAssignmentPage( + total = assignedAddressHandler.count(filter), + items = assignedAddressHandler.search(filter) + ) + } + + override suspend fun findById(id: Long): AddressAssignmentRecord { + return assignedAddressHandler.findRecordById(id) ?: throw OpexError.IdentifierNotFound.exception() + } + + override suspend fun listAvailableAddresses(filter: ReservedAddressFilter): ReservedAddressPage { + return ReservedAddressPage( + total = reservedAddressHandler.count(filter), + items = reservedAddressHandler.search(filter) + ) + } + + @Transactional + override suspend fun assign(request: AdminAssignAddressRequest): AddressAssignmentRecord { + if (request.uuid.isBlank()) throw OpexError.InvalidRequestBody.exception("uuid is required") + val reserved = claimAddress(request) + assignedAddressHandler.findUuid(reserved.address, reserved.memo)?.let { holder -> + logger.error("Reserved address ${reserved.address} is still assigned to $holder") + throw OpexError.AddressAlreadyAssigned.exception() + } + + val expTime = request.expTime?.toLocalDateTime() + ?: addressLifeTime?.let { LocalDateTime.now().plusSeconds(it) } + + return assignedAddressHandler.persistAssignment( + AssignedAddress( + request.uuid, + reserved.address, + reserved.memo, + reserved.type, + mutableListOf(), + expTime, + LocalDateTime.now(), + null, + AddressStatus.Assigned, + null + ) + ) + } + + @Transactional + override suspend fun revoke(id: Long): AddressAssignmentRecord { + val record = findById(id) + if (record.status != AddressStatus.Assigned) + throw OpexError.BadRequest.exception("Only an assigned address can be revoked") + val revoked = assignedAddressHandler.revokeById(id, LocalDateTime.now()) + val type = record.addressType + if (type == null) { + logger.warn("Assignment $id has an unknown address type, not returning ${record.address} to the pool") + } else if (reservedAddressHandler.findByAddress(record.address, record.memo) == null) { + reservedAddressHandler.addReservedAddress(listOf(ReservedAddress(record.address, record.memo, type))) + } + return revoked + } + + private suspend fun claimAddress(request: AdminAssignAddressRequest): ReservedAddress { + request.address?.takeIf { it.isNotBlank() }?.let { address -> + return reservedAddressHandler.claimByAddress(address, request.memo) + ?: throw OpexError.BadRequest.exception( + "Address $address is not available in the reserved pool, pick one from the available list" + ) + } + + return reservedAddressHandler.claimAny() + ?: throw OpexError.ReservedAddressNotAvailable.exception() + } + + private fun Long.toLocalDateTime(): LocalDateTime = + LocalDateTime.ofInstant(Instant.ofEpochMilli(this), ZoneId.systemDefault()) +} diff --git a/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/service/AssignAddressServiceImpl.kt b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/service/AssignAddressServiceImpl.kt index 9805c8f47..cf1b15f87 100644 --- a/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/service/AssignAddressServiceImpl.kt +++ b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/service/AssignAddressServiceImpl.kt @@ -46,7 +46,7 @@ open class AssignAddressServiceImpl( if (assigned != null) { result.add(assigned) } else { - val reservedAddress = reservedAddressHandler.peekReservedAddress(addressType) + val reservedAddress = reservedAddressHandler.claimNext(addressType) if (reservedAddress != null) { val newAssigned = AssignedAddress( user, @@ -61,7 +61,6 @@ open class AssignAddressServiceImpl( AddressStatus.Assigned, null ) - reservedAddressHandler.remove(reservedAddress) result.add(newAssigned) } else { logger.info("No reserved address available for $addressType") diff --git a/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/spi/AssignedAddressHandler.kt b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/spi/AssignedAddressHandler.kt index ad506df02..4806408b1 100644 --- a/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/spi/AssignedAddressHandler.kt +++ b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/spi/AssignedAddressHandler.kt @@ -1,5 +1,7 @@ package co.nilin.opex.bcgateway.core.spi +import co.nilin.opex.bcgateway.core.model.AddressAssignmentFilter +import co.nilin.opex.bcgateway.core.model.AddressAssignmentRecord import co.nilin.opex.bcgateway.core.model.AddressStatus import co.nilin.opex.bcgateway.core.model.AddressType import co.nilin.opex.bcgateway.core.model.AssignedAddress @@ -21,4 +23,14 @@ interface AssignedAddressHandler { * Returns Pair, where both can be null when unassigned. */ suspend fun findHolder(address: String, memo: String?, at: LocalDateTime?): Pair + + suspend fun persistAssignment(assignedAddress: AssignedAddress): AddressAssignmentRecord + + suspend fun search(filter: AddressAssignmentFilter): List + + suspend fun count(filter: AddressAssignmentFilter): Long + + suspend fun findRecordById(id: Long): AddressAssignmentRecord? + + suspend fun revokeById(id: Long, revokedAt: LocalDateTime): AddressAssignmentRecord } \ No newline at end of file diff --git a/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/spi/ReservedAddressHandler.kt b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/spi/ReservedAddressHandler.kt index d970c6cb9..3f8588564 100644 --- a/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/spi/ReservedAddressHandler.kt +++ b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/spi/ReservedAddressHandler.kt @@ -2,9 +2,34 @@ package co.nilin.opex.bcgateway.core.spi import co.nilin.opex.bcgateway.core.model.AddressType import co.nilin.opex.bcgateway.core.model.ReservedAddress +import co.nilin.opex.bcgateway.core.model.ReservedAddressFilter +import co.nilin.opex.bcgateway.core.model.ReservedAddressRecord interface ReservedAddressHandler { suspend fun addReservedAddress(list: List) - suspend fun peekReservedAddress(addressType: AddressType): ReservedAddress? - suspend fun remove(reservedAddress: ReservedAddress) -} \ No newline at end of file + + /** + * Takes one free address of this type out of the pool, atomically. Returns null when nothing + * is left. This replaces reading an address and deleting it in two steps, which let two + * concurrent assignments pick the same address. + */ + suspend fun claimNext(addressType: AddressType): ReservedAddress? + + /** + * Takes any free address out of the pool, atomically. The claimed address carries its own + * type, so the caller does not have to pick one up front. Returns null when the pool is empty. + */ + suspend fun claimAny(): ReservedAddress? + + /** + * Takes this exact address out of the pool, atomically. Returns null when it is not in the + * pool at all or another request claimed it first. + */ + suspend fun claimByAddress(address: String, memo: String?): ReservedAddress? + + suspend fun findByAddress(address: String, memo: String?): ReservedAddress? + + suspend fun search(filter: ReservedAddressFilter): List + + suspend fun count(filter: ReservedAddressFilter): Long +} diff --git a/bc-gateway/bc-gateway-core/src/test/kotlin/co/nilin/opex/bcgateway/core/service/AssignAddressServiceImplUnitTest.kt b/bc-gateway/bc-gateway-core/src/test/kotlin/co/nilin/opex/bcgateway/core/service/AssignAddressServiceImplUnitTest.kt index 404845d6c..6c729a44b 100644 --- a/bc-gateway/bc-gateway-core/src/test/kotlin/co/nilin/opex/bcgateway/core/service/AssignAddressServiceImplUnitTest.kt +++ b/bc-gateway/bc-gateway-core/src/test/kotlin/co/nilin/opex/bcgateway/core/service/AssignAddressServiceImplUnitTest.kt @@ -104,7 +104,6 @@ class AssignAddressServiceImplUnitTest { coEvery { assignedAddressHandler.persist(any()) } returns Unit - coEvery { reservedAddressHandler.remove(any()) } returns Unit } @@ -120,13 +119,13 @@ class AssignAddressServiceImplUnitTest { ) } returns emptyList() - coEvery { reservedAddressHandler.peekReservedAddress(ethAddressType) } returns ReservedAddress( + coEvery { reservedAddressHandler.claimNext(ethAddressType) } returns ReservedAddress( "0x1", null, ethAddressType ) - coEvery { reservedAddressHandler.peekReservedAddress(ethMemoAddressType) } returns ReservedAddress( + coEvery { reservedAddressHandler.claimNext(ethMemoAddressType) } returns ReservedAddress( "0x2", "Memo", ethMemoAddressType @@ -156,7 +155,7 @@ class AssignAddressServiceImplUnitTest { listOf(ethAddressType, ethMemoAddressType) ) } returns emptyList() - coEvery { reservedAddressHandler.peekReservedAddress(ethAddressType) } returns null + coEvery { reservedAddressHandler.claimNext(ethAddressType) } returns null coEvery { assignAddressServiceImpl.assignAddress(user, currency, gatewayUuid) } throws RuntimeException() } @@ -178,12 +177,12 @@ class AssignAddressServiceImplUnitTest { mutableListOf(ethChain) ) ) - coEvery { reservedAddressHandler.peekReservedAddress(ethAddressType) } returns ReservedAddress( + coEvery { reservedAddressHandler.claimNext(ethAddressType) } returns ReservedAddress( "0x1", null, ethAddressType ) - coEvery { reservedAddressHandler.peekReservedAddress(ethMemoAddressType) } returns ReservedAddress( + coEvery { reservedAddressHandler.claimNext(ethMemoAddressType) } returns ReservedAddress( "0x2", "Memo", ethMemoAddressType diff --git a/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/dao/AssignedAddressRepository.kt b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/dao/AssignedAddressRepository.kt index e58e92dd5..f8bfbc3ac 100644 --- a/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/dao/AssignedAddressRepository.kt +++ b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/dao/AssignedAddressRepository.kt @@ -47,4 +47,58 @@ interface AssignedAddressRepository : ReactiveCrudRepository + + @Query( + """ + SELECT * FROM assigned_addresses a + WHERE (:uuid IS NULL OR a.uuid = :uuid) + AND (:address IS NULL OR a.address = :address) + AND (:memo IS NULL OR a.memo = :memo) + AND (:status IS NULL OR a.status = :status) + AND (:addressTypeId IS NULL OR a.addr_type_id = :addressTypeId) + AND (:chain IS NULL OR a.addr_type_id IN + (SELECT addr_type_id FROM chain_address_types WHERE chain_name = :chain)) + AND (:assignedFrom IS NULL OR a.assigned_date >= :assignedFrom) + AND (:assignedTo IS NULL OR a.assigned_date <= :assignedTo) + ORDER BY a.assigned_date DESC, a.id DESC + LIMIT :limit OFFSET :offset +""" + ) + fun searchAssignments( + @Param("uuid") uuid: String?, + @Param("address") address: String?, + @Param("memo") memo: String?, + @Param("status") status: AddressStatus?, + @Param("addressTypeId") addressTypeId: Long?, + @Param("chain") chain: String?, + @Param("assignedFrom") assignedFrom: LocalDateTime?, + @Param("assignedTo") assignedTo: LocalDateTime?, + @Param("limit") limit: Int, + @Param("offset") offset: Int + ): Flow + + @Query( + """ + SELECT count(*) FROM assigned_addresses a + WHERE (:uuid IS NULL OR a.uuid = :uuid) + AND (:address IS NULL OR a.address = :address) + AND (:memo IS NULL OR a.memo = :memo) + AND (:status IS NULL OR a.status = :status) + AND (:addressTypeId IS NULL OR a.addr_type_id = :addressTypeId) + AND (:chain IS NULL OR a.addr_type_id IN + (SELECT addr_type_id FROM chain_address_types WHERE chain_name = :chain)) + AND (:assignedFrom IS NULL OR a.assigned_date >= :assignedFrom) + AND (:assignedTo IS NULL OR a.assigned_date <= :assignedTo) +""" + ) + fun countAssignments( + @Param("uuid") uuid: String?, + @Param("address") address: String?, + @Param("memo") memo: String?, + @Param("status") status: AddressStatus?, + @Param("addressTypeId") addressTypeId: Long?, + @Param("chain") chain: String?, + @Param("assignedFrom") assignedFrom: LocalDateTime?, + @Param("assignedTo") assignedTo: LocalDateTime? + ): Mono } \ No newline at end of file diff --git a/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/dao/ReservedAddressRepository.kt b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/dao/ReservedAddressRepository.kt index b39d08d55..7c5dc2ce6 100644 --- a/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/dao/ReservedAddressRepository.kt +++ b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/dao/ReservedAddressRepository.kt @@ -1,20 +1,101 @@ package co.nilin.opex.bcgateway.ports.postgres.dao import co.nilin.opex.bcgateway.ports.postgres.model.ReservedAddressModel -import org.springframework.data.r2dbc.repository.Modifying +import kotlinx.coroutines.flow.Flow import org.springframework.data.r2dbc.repository.Query +import org.springframework.data.repository.query.Param import org.springframework.data.repository.reactive.ReactiveCrudRepository import org.springframework.stereotype.Repository -import reactor.core.publisher.Flux import reactor.core.publisher.Mono @Repository interface ReservedAddressRepository : ReactiveCrudRepository { - @Query("select * from reserved_addresses where address_type = :addressType order by id DESC") - fun peekFirstAdded(addressType: Long): Flux + @Query("select * from reserved_addresses where address = :address and (memo is null or memo = '' or memo = :memo) limit 1") + fun findByAddressAndMemo(address: String, memo: String?): Mono - @Modifying - @Query("delete from reserved_addresses where address = :address and (memo is null or memo = :memo)") - fun remove(address: String, memo: String?): Mono + /** + * Takes one address out of the pool in a single statement and returns it. The row lock plus + * `SKIP LOCKED` makes concurrent callers land on different rows instead of racing for the same + * one, and because the DELETE is what marks an address as taken, two users can never end up + * holding it. Returns empty when the pool has nothing left for this type. + * + * Ordered by id rather than at random so it stays an index scan: this runs on the deposit + * path too, and the caller only needs "an address I did not choose", not an unpredictable one. + */ + @Query( + """ + DELETE FROM reserved_addresses + WHERE id = (SELECT id FROM reserved_addresses + WHERE address_type = :addressType + ORDER BY id + LIMIT 1 FOR UPDATE SKIP LOCKED) + RETURNING * +""" + ) + fun claimByType(@Param("addressType") addressType: Long): Mono + + /** + * Same atomic claim as [claimByType] without narrowing the type, for the admin flow where the + * pool decides which address to hand out and therefore also decides its type. + */ + @Query( + """ + DELETE FROM reserved_addresses + WHERE id = (SELECT id FROM reserved_addresses + ORDER BY id + LIMIT 1 FOR UPDATE SKIP LOCKED) + RETURNING * +""" + ) + fun claimAny(): Mono + + /** + * Same atomic claim as [claimByType] for an address the admin picked from the pool listing. + * Returns empty when it is already gone, which is how a double assignment is detected. + */ + @Query( + """ + DELETE FROM reserved_addresses + WHERE id = (SELECT id FROM reserved_addresses + WHERE address = :address AND coalesce(memo, '') = :memo + LIMIT 1 FOR UPDATE SKIP LOCKED) + RETURNING * +""" + ) + fun claimByAddress(@Param("address") address: String, @Param("memo") memo: String): Mono + + @Query( + """ + SELECT * FROM reserved_addresses r + WHERE (:addressTypeId IS NULL OR r.address_type = :addressTypeId) + AND (:chain IS NULL OR r.address_type IN + (SELECT addr_type_id FROM chain_address_types WHERE chain_name = :chain)) + AND (:address IS NULL OR r.address = :address) + ORDER BY r.id + LIMIT :limit OFFSET :offset +""" + ) + fun searchReserved( + @Param("addressTypeId") addressTypeId: Long?, + @Param("chain") chain: String?, + @Param("address") address: String?, + @Param("limit") limit: Int, + @Param("offset") offset: Int + ): Flow + + @Query( + """ + SELECT count(*) FROM reserved_addresses r + WHERE (:addressTypeId IS NULL OR r.address_type = :addressTypeId) + AND (:chain IS NULL OR r.address_type IN + (SELECT addr_type_id FROM chain_address_types WHERE chain_name = :chain)) + AND (:address IS NULL OR r.address = :address) +""" + ) + fun countReserved( + @Param("addressTypeId") addressTypeId: Long?, + @Param("chain") chain: String?, + @Param("address") address: String? + ): Mono } diff --git a/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/impl/AssignedAddressHandlerImpl.kt b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/impl/AssignedAddressHandlerImpl.kt index f820a56ae..d80a9a3d2 100644 --- a/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/impl/AssignedAddressHandlerImpl.kt +++ b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/impl/AssignedAddressHandlerImpl.kt @@ -1,5 +1,7 @@ package co.nilin.opex.bcgateway.ports.postgres.impl +import co.nilin.opex.bcgateway.core.model.AddressAssignmentFilter +import co.nilin.opex.bcgateway.core.model.AddressAssignmentRecord import co.nilin.opex.bcgateway.core.model.AddressStatus import co.nilin.opex.bcgateway.core.model.AddressType import co.nilin.opex.bcgateway.core.model.AssignedAddress @@ -10,6 +12,8 @@ import co.nilin.opex.bcgateway.ports.postgres.dao.AddressTypeRepository import co.nilin.opex.bcgateway.ports.postgres.dao.AssignedAddressChainRepository import co.nilin.opex.bcgateway.ports.postgres.dao.AssignedAddressRepository import co.nilin.opex.bcgateway.ports.postgres.model.AssignedAddressModel +import co.nilin.opex.bcgateway.ports.postgres.util.toRecord +import co.nilin.opex.common.OpexError import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.toList @@ -17,8 +21,11 @@ import kotlinx.coroutines.reactive.awaitFirst import kotlinx.coroutines.reactive.awaitFirstOrNull import org.slf4j.Logger import org.springframework.beans.factory.annotation.Value +import org.springframework.dao.DataIntegrityViolationException import org.springframework.stereotype.Service +import java.time.Instant import java.time.LocalDateTime +import java.time.ZoneId @Service class AssignedAddressHandlerImpl( @@ -118,6 +125,81 @@ class AssignedAddressHandlerImpl( return Pair(atModel?.uuid, atModel?.status) } + override suspend fun persistAssignment(assignedAddress: AssignedAddress): AddressAssignmentRecord { + val saved = try { + assignedAddressRepository.save( + AssignedAddressModel( + null, + assignedAddress.uuid, + assignedAddress.address, + assignedAddress.memo, + assignedAddress.type.id, + assignedAddress.expTime, + assignedAddress.assignedDate ?: LocalDateTime.now(), + null, + assignedAddress.status + ) + ).awaitFirstOrNull() ?: throw OpexError.BadRequest.exception("Failed to save address assignment") + } catch (e: DataIntegrityViolationException) { + logger.warn("Duplicate assignment for ${assignedAddress.address} at ${assignedAddress.expTime}") + throw OpexError.AddressAlreadyAssigned.exception() + } + return saved.toRecord(addressTypes()) + } + + override suspend fun search(filter: AddressAssignmentFilter): List { + val addressTypeMap = addressTypes() + return assignedAddressRepository.searchAssignments( + uuid = filter.uuid, + address = filter.address, + memo = filter.memo, + status = filter.status, + addressTypeId = filter.addressTypeId, + chain = filter.chain, + assignedFrom = filter.assignedFrom?.toLocalDateTime(), + assignedTo = filter.assignedTo?.toLocalDateTime(), + limit = filter.limit, + offset = filter.offset + ).map { it.toRecord(addressTypeMap) }.toList() + } + + override suspend fun count(filter: AddressAssignmentFilter): Long { + return assignedAddressRepository.countAssignments( + uuid = filter.uuid, + address = filter.address, + memo = filter.memo, + status = filter.status, + addressTypeId = filter.addressTypeId, + chain = filter.chain, + assignedFrom = filter.assignedFrom?.toLocalDateTime(), + assignedTo = filter.assignedTo?.toLocalDateTime() + ).awaitFirstOrNull() ?: 0 + } + + override suspend fun findRecordById(id: Long): AddressAssignmentRecord? { + val model = assignedAddressRepository.findById(id).awaitFirstOrNull() ?: return null + return model.toRecord(addressTypes()) + } + + override suspend fun revokeById(id: Long, revokedAt: LocalDateTime): AddressAssignmentRecord { + val model = assignedAddressRepository.findById(id).awaitFirstOrNull() + ?: throw OpexError.IdentifierNotFound.exception() + val revoked = assignedAddressRepository.save( + model.copy(status = AddressStatus.Revoked, revokedDate = revokedAt) + ).awaitFirstOrNull() ?: throw OpexError.BadRequest.exception("Failed to revoke assignment $id") + return revoked.toRecord(addressTypes()) + } + + private suspend fun addressTypes(): Map { + return addressTypeRepository.findAll() + .map { AddressType(it.id!!, it.type, it.addressRegex, it.memoRegex) } + .collectMap { it.id } + .awaitFirst() + } + + private fun Long.toLocalDateTime(): LocalDateTime = + LocalDateTime.ofInstant(Instant.ofEpochMilli(this), ZoneId.systemDefault()) + private suspend fun AssignedAddressModel.toDto(addressTypeMap: MutableMap): AssignedAddress { return AssignedAddress( this.uuid, diff --git a/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/impl/ReservedAddressHandlerImpl.kt b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/impl/ReservedAddressHandlerImpl.kt index a6c90cb53..6ce2cd713 100644 --- a/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/impl/ReservedAddressHandlerImpl.kt +++ b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/impl/ReservedAddressHandlerImpl.kt @@ -2,10 +2,14 @@ package co.nilin.opex.bcgateway.ports.postgres.impl import co.nilin.opex.bcgateway.core.model.AddressType import co.nilin.opex.bcgateway.core.model.ReservedAddress +import co.nilin.opex.bcgateway.core.model.ReservedAddressFilter +import co.nilin.opex.bcgateway.core.model.ReservedAddressRecord import co.nilin.opex.bcgateway.core.spi.AddressTypeHandler import co.nilin.opex.bcgateway.core.spi.ReservedAddressHandler import co.nilin.opex.bcgateway.ports.postgres.dao.ReservedAddressRepository import co.nilin.opex.bcgateway.ports.postgres.model.ReservedAddressModel +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.toList import kotlinx.coroutines.reactive.awaitFirst import kotlinx.coroutines.reactive.awaitFirstOrNull import org.springframework.stereotype.Component @@ -20,12 +24,46 @@ class ReservedAddressHandlerImpl( reservedAddressRepository.saveAll(items).collectList().awaitFirst() } - override suspend fun peekReservedAddress(addressType: AddressType): ReservedAddress? { - return reservedAddressRepository.peekFirstAdded(addressType.id) - .map { ReservedAddress(it.address, it.memo, addressType) }.awaitFirstOrNull() + override suspend fun claimNext(addressType: AddressType): ReservedAddress? { + val model = reservedAddressRepository.claimByType(addressType.id).awaitFirstOrNull() ?: return null + return ReservedAddress(model.address, model.memo, addressType) } - override suspend fun remove(reservedAddress: ReservedAddress) { - reservedAddressRepository.remove(reservedAddress.address, reservedAddress.memo).awaitFirst() + override suspend fun claimAny(): ReservedAddress? { + val model = reservedAddressRepository.claimAny().awaitFirstOrNull() ?: return null + val type = addressTypeHandler.fetchAll().firstOrNull { it.id == model.type } ?: return null + return ReservedAddress(model.address, model.memo, type) + } + + override suspend fun claimByAddress(address: String, memo: String?): ReservedAddress? { + // reserved_addresses.memo is NOT NULL, so an absent memo means the empty string + val model = reservedAddressRepository.claimByAddress(address, memo ?: "").awaitFirstOrNull() ?: return null + val type = addressTypeHandler.fetchAll().firstOrNull { it.id == model.type } ?: return null + return ReservedAddress(model.address, model.memo, type) + } + + override suspend fun findByAddress(address: String, memo: String?): ReservedAddress? { + val model = reservedAddressRepository.findByAddressAndMemo(address, memo).awaitFirstOrNull() ?: return null + val type = addressTypeHandler.fetchAll().firstOrNull { it.id == model.type } ?: return null + return ReservedAddress(model.address, model.memo, type) + } + + override suspend fun search(filter: ReservedAddressFilter): List { + val addressTypes = addressTypeHandler.fetchAll().associateBy { it.id } + return reservedAddressRepository.searchReserved( + addressTypeId = filter.addressTypeId, + chain = filter.chain, + address = filter.address, + limit = filter.limit, + offset = filter.offset + ).map { ReservedAddressRecord(it.address, it.memo, addressTypes[it.type]) }.toList() + } + + override suspend fun count(filter: ReservedAddressFilter): Long { + return reservedAddressRepository.countReserved( + addressTypeId = filter.addressTypeId, + chain = filter.chain, + address = filter.address + ).awaitFirstOrNull() ?: 0 } } diff --git a/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/util/Convertor.kt b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/util/Convertor.kt index 2e6fa1d4a..7eea2bbed 100644 --- a/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/util/Convertor.kt +++ b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/util/Convertor.kt @@ -1,8 +1,11 @@ package co.nilin.opex.bcgateway.ports.postgres.util +import co.nilin.opex.bcgateway.core.model.AddressAssignmentRecord +import co.nilin.opex.bcgateway.core.model.AddressType import co.nilin.opex.bcgateway.core.model.CryptoCurrencyCommand import co.nilin.opex.bcgateway.core.model.CurrencyOnChainGatewayLocalizationCommand import co.nilin.opex.bcgateway.core.model.CurrencyOnChainGatewayView +import co.nilin.opex.bcgateway.ports.postgres.model.AssignedAddressModel import co.nilin.opex.bcgateway.ports.postgres.model.CurrencyOnChainGatewayLocalizationModel import co.nilin.opex.bcgateway.ports.postgres.model.CurrencyOnChainGatewayModel @@ -91,4 +94,16 @@ fun CurrencyOnChainGatewayLocalizationModel.toCommand(): CurrencyOnChainGatewayL withdrawDescription = withdrawDescription, language = language ) -} \ No newline at end of file +} + +fun AssignedAddressModel.toRecord(addressTypeMap: Map) = AddressAssignmentRecord( + id = id!!, + uuid = uuid, + address = address, + memo = memo, + addressType = addressTypeMap[addressTypeId], + assignedDate = assignedDate, + expTime = expTime, + revokedDate = revokedDate, + status = status +) \ No newline at end of file diff --git a/common/src/main/kotlin/co/nilin/opex/common/OpexError.kt b/common/src/main/kotlin/co/nilin/opex/common/OpexError.kt index 2ed67cebe..1c5bd4520 100644 --- a/common/src/main/kotlin/co/nilin/opex/common/OpexError.kt +++ b/common/src/main/kotlin/co/nilin/opex/common/OpexError.kt @@ -143,6 +143,11 @@ enum class OpexError(val code: Int, val message: String?, val status: HttpStatus CurrencyNotFoundBC(8004, "Currency not found", HttpStatus.NOT_FOUND), TokenNotFound(8005, "Coin/Token not found", HttpStatus.NOT_FOUND), InvalidAddressType(8006, "Address type is invalid", HttpStatus.NOT_FOUND), + AddressAlreadyAssigned( + 8007, + "This address is already assigned with the same expiration time", + HttpStatus.CONFLICT + ), // code 10000: bc-gateway InvalidCaptcha(10001, "Captcha is not valid", HttpStatus.BAD_REQUEST),