Skip to content
This repository was archived by the owner on Dec 12, 2024. It is now read-only.

Add type-safe layer and filtering #23

Merged
merged 9 commits into from
Jul 5, 2024
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Change Log

## [0.5.2] - 2024-07-04
- add type-safe layer (TypedMoneyAddress etc).
- add logging

## [0.5.1] - 2024-06-26
- Fix Dap.toString()

Expand Down
23 changes: 21 additions & 2 deletions src/main/kotlin/xyz/block/dap/Dap.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,29 @@ package xyz.block.dap

import java.util.regex.Pattern

/**
* A Decentralized Agnostic Paytag (DAP).
*
* A DAP consists of
* @property handle - a `handle` which is a unique identifier for the user within the domain
* @property domain - the domain of the DAP registry, effectively scopes the handles
*/
data class Dap(
val handle: String,
val domain: String
) {
/**
* The canonical string format of a DAP is `@handle/domain`.
* An example would be `@moegrammer/didpay.me`.
* In this case, `moegrammer` is the handle and `didpay.me` is the domain.
*/
override fun toString(): String {
return "$PREFIX$handle$SEPARATOR$domain"
}

/**
* Transform the DAP to the web DID format, used for DAP resolution.
*/
fun toWebDid(): String {
return "did:web:${domain}"
}
Expand All @@ -18,12 +33,16 @@ data class Dap(
const val PREFIX = "@"
const val SEPARATOR = "/"

const val SERVICE_TYPE = "DAPRegistry"

private const val DAP_REGEX =
"""^$PREFIX([^$PREFIX$SEPARATOR]{3,30})$SEPARATOR([^$PREFIX$SEPARATOR]+)$"""
private val DAP_PATTERN = Pattern.compile(DAP_REGEX)

/**
* Parse a string into a DAP.
* The string must match the canonical format of a DAP (i.e. `@handle/domain`).
*
* @throws InvalidDapException if the DAP is not a valid DAP.
*/
fun parse(dap: String): Dap {
val matcher = DAP_PATTERN.matcher(dap)
matcher.find()
Expand Down
3 changes: 2 additions & 1 deletion src/main/kotlin/xyz/block/dap/DapResolver.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ import xyz.block.moneyaddress.MoneyAddress
* See the [DAP spec](https://github.com/TBD54566975/dap#resolution) for the resolution process.
*
* This wires together the RegistryResolver, RegistryDidResolver, and MoneyAddressResolver.
*
* The RegistryDidResolver will use the default configuration unless an instance is provided that
* is constructed with a block configuration override.
* is constructed with a block configuration override (e.g. to change the HTTP engine configuration).
*/
class DapResolver(
private val registryDidResolver: RegistryDidResolver = RegistryDidResolver(),
Expand Down
9 changes: 8 additions & 1 deletion src/main/kotlin/xyz/block/dap/RegistryResolver.kt
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ class RegistryResolver {
}

private fun findDapRegistryUrl(didDocument: DidDocument, did: String): String {
val service = didDocument.service?.find { it.type == Dap.SERVICE_TYPE }
val service = didDocument.service?.find { it.type == SERVICE_TYPE }
?: throw RegistryResolutionException("DID document has no DAP registry service [did=$did]")

if (service.serviceEndpoint.isEmpty()) {
Expand All @@ -58,6 +58,13 @@ class RegistryResolver {
}

private val logger = KotlinLogging.logger {}

companion object {
/**
* The service type of DAP registries in a DID Document.
*/
const val SERVICE_TYPE = "DAPRegistry"
}
}

class RegistryResolutionException : Throwable {
Expand Down
34 changes: 34 additions & 0 deletions src/main/kotlin/xyz/block/moneyaddress/Currency.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package xyz.block.moneyaddress

/**
* Represents the currency of a MoneyAddress.
*
* @property scheme the string representation of the currency as used in the DID Documents.
*
* Currencies mentioned in the DAP specification have concrete implementations.
* This allows type-safe matching of MoneyAddress objects without matching on strings.
* See [TypedMoneyAddress] and [Filter.kt]
*
* Currencies not included in the DAP specification are represented by [UNRECOGNIZED_CURRENCY].
*/
open class Currency(open val scheme: String) {
override fun toString(): String = scheme

companion object {
fun String.asCurrency(): Currency =
when (this) {
BTC.scheme -> BTC
KES.scheme -> KES
USDC.scheme -> USDC
ZAR.scheme -> ZAR
else -> UNRECOGNIZED_CURRENCY(this)
}
}
}

data object BTC : Currency("btc")
data object KES : Currency("kes")
data object USDC : Currency("usdc")
data object ZAR : Currency("zar")

data class UNRECOGNIZED_CURRENCY(override val scheme: String) : Currency(scheme)
41 changes: 41 additions & 0 deletions src/main/kotlin/xyz/block/moneyaddress/Filter.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package xyz.block.moneyaddress

/**
* Utility extension functions to be used for filtering [MoneyAddress] or [TypedMoneyAddress] objects.
*/

/**
* Returns true if the [MoneyAddress] has the given currency.
*/
fun MoneyAddress.hasCurrency(currency: Currency): Boolean =
hasCurrency(currency.scheme)

/**
* Returns true if the [MoneyAddress] has the given currency.
*/
fun MoneyAddress.hasCurrency(currency: String): Boolean =
this.currency == currency

/**
* Returns true if the [MoneyAddress] has the given protocol.
*/
fun MoneyAddress.hasProtocol(protocol: Protocol): Boolean =
hasProtocol(protocol.scheme)

/**
* Returns true if the [MoneyAddress] has the given protocol.
*/
fun MoneyAddress.hasProtocol(protocol: String): Boolean =
this.protocol == protocol

/**
* Returns true if the [MoneyAddress] has the given currency and protocol.
*/
fun MoneyAddress.matches(currency: Currency, protocol: Protocol): Boolean =
hasProtocol(protocol) && hasCurrency(currency)

/**
* Returns true if the [MoneyAddress] has the given currency and protocol.
*/
fun MoneyAddress.matches(currency: String, protocol: String): Boolean =
hasCurrency(currency) && hasProtocol(protocol)
39 changes: 29 additions & 10 deletions src/main/kotlin/xyz/block/moneyaddress/MoneyAddress.kt
Original file line number Diff line number Diff line change
@@ -1,36 +1,55 @@
package xyz.block.moneyaddress

import web5.sdk.dids.didcore.Service
import xyz.block.moneyaddress.MoneyAddress.Companion.KIND
import xyz.block.moneyaddress.urn.DapUrn

/**
* A generic representation of a MoneyAddress, without specific details of the currency or protocol.
*
* For a more specific representation, see [TypedMoneyAddress].
*/
data class MoneyAddress(
val id: String,
val urn: DapUrn,
val currency: String,
val protocol: String,
val pss: String
) {

override fun toString(): String =
"MoneyAddress($currency, $protocol, $pss, $id)"

companion object {
const val KIND: String = "MoneyAddress"
}
}

/**
* Extracts the MoneyAddress objects from a DID Service object.
*
* @throws InvalidMoneyAddressException if the service type is not "MoneyAddress".
* @throws InvalidDapUrnException if the URN is not a valid DAP URN.
*/
fun Service.toMoneyAddresses(): List<MoneyAddress> {
if (type != KIND) {
if (type != MoneyAddress.KIND) {
throw InvalidMoneyAddressException
}

return serviceEndpoint.map { endpoint ->
val urn = DapUrn.parse(endpoint)
MoneyAddress(
id = id,
urn = urn,
currency = urn.currency,
protocol = urn.protocol,
pss = urn.pss
)
DapUrn.parse(endpoint).toMoneyAddress(id)
}
}

/**
* Converts a DAP URN to a MoneyAddress.
*/
fun DapUrn.toMoneyAddress(id: String): MoneyAddress =
MoneyAddress(
id = id,
urn = this,
currency = currency,
protocol = protocol,
pss = pss
)

object InvalidMoneyAddressException : Throwable("Invalid MoneyAddress")
39 changes: 39 additions & 0 deletions src/main/kotlin/xyz/block/moneyaddress/Protocol.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package xyz.block.moneyaddress

/**
* Represents the protocol of a MoneyAddress.
* These protocols are often, but not always, specific to a currency.
*
* @property scheme the string representation of the protocol as used in the DID Documents.
*
* Protocols mentioned in the DAP specification have concrete implementations.
* This allows type-safe matching of MoneyAddress objects without matching on strings.
* See [TypedMoneyAddress] and [Filter.kt]
*
* Protocols not included in the DAP specification are represented by [UNRECOGNIZED_PROTOCOL].
*/
open class Protocol(open val scheme: String) {
override fun toString(): String = scheme

companion object {
fun String.asProtocol(): Protocol =
when (this) {
ETHEREUM.scheme -> ETHEREUM
LIGHTNING_ADDRESS.scheme -> LIGHTNING_ADDRESS
MOBILE_MONEY.scheme -> MOBILE_MONEY
ONCHAIN_ADDRESS.scheme -> ONCHAIN_ADDRESS
SILENT_PAYMENT_ADDRESS.scheme -> SILENT_PAYMENT_ADDRESS
STELLAR_XLM.scheme -> STELLAR_XLM
else -> UNRECOGNIZED_PROTOCOL(this)
}
}
}

data object ETHEREUM : Protocol("eth")
data object LIGHTNING_ADDRESS : Protocol("lnaddr")
data object MOBILE_MONEY : Protocol("momo")
data object ONCHAIN_ADDRESS : Protocol("addr")
data object SILENT_PAYMENT_ADDRESS : Protocol("spaddr")
data object STELLAR_XLM : Protocol("xlm")

data class UNRECOGNIZED_PROTOCOL(override val scheme: String) : Protocol(scheme)
36 changes: 36 additions & 0 deletions src/main/kotlin/xyz/block/moneyaddress/TypedMoneyAddress.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package xyz.block.moneyaddress

import xyz.block.moneyaddress.Currency.Companion.asCurrency
import xyz.block.moneyaddress.Protocol.Companion.asProtocol

/**
* A type-safe representation of a MoneyAddress provided as a layer on top of [MoneyAddress]
*
* The `currency` and `protocol` are typed, allowing filtering and matching without string comparison.
* See [Filter.kt] for utility functions for this matching.
*
* See [TypedMoneyAddressRegistry] for semi-automatic conversion of [MoneyAddress] to [TypedMoneyAddress].
*/
open class TypedMoneyAddress<T>(
open val address: T,
open val currency: Currency,
open val protocol: Protocol,
open val id: String,
) {
override fun toString(): String =
"MoneyAddress(${currency.scheme}, ${protocol.scheme}, $address, $id)"
}

/**
* Represents a MoneyAddress that is not recognized by the [TypedMoneyAddressRegistry].
*/
data class UnrecognizedMoneyAddress(
val pss: String,
override val currency: Currency,
override val protocol: Protocol,
override val id: String
) : TypedMoneyAddress<String>(pss, currency, protocol, id) {

constructor(pss: String, currency: String, protocol: String, id: String) :
this(pss, currency.asCurrency(), protocol.asProtocol(), id)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package xyz.block.moneyaddress

import io.github.oshai.kotlinlogging.KotlinLogging
import xyz.block.moneyaddress.Currency.Companion.asCurrency
import xyz.block.moneyaddress.Protocol.Companion.asProtocol
import xyz.block.moneyaddress.typed.BtcLightningAddress
import xyz.block.moneyaddress.typed.BtcOnChainAddress
import xyz.block.moneyaddress.typed.MobileMoneyAddress
import xyz.block.moneyaddress.MoneyAddress as UntypedMoneyAddress

/**
* A factory function that converts an [UntypedMoneyAddress] to a [TypedMoneyAddress].
* These are registered with the [TypedMoneyAddressRegistry] in order to allow automatic conversion to the typed classes.
*/
typealias TypedMoneyAddressFactory<T> = (UntypedMoneyAddress) -> TypedMoneyAddress<T>

/**
* Provides type conversion from [MoneyAddress] to [TypedMoneyAddress].
* This is based on registered mappings from [Currency] and [Protocol] to [TypedMoneyAddressFactory] functions.
*
* There is a `defaultTypedMoneyAddressRegistry` that is pre-populated with the default mappings.
* New mappings can be added using the `register` method, for either a `Currency` and `Protocol` pair or just a `Protocol`.
* Mappings for the `Currency` and `Protocol` pair take precendence over mappings for just the `Protocol`.
*/
class TypedMoneyAddressRegistry {
fun <T> register(
currency: Currency,
protocol: Protocol,
f: TypedMoneyAddressFactory<T>
): TypedMoneyAddressRegistry {
if (cpMappings.put(Pair(currency, protocol), f) != null) {
logger.warn {
"Overwriting existing MoneyAddressRegistry entry [currency=$currency][protocol=$protocol]"
}
}
return this
}

fun <T> register(
protocol: Protocol,
f: TypedMoneyAddressFactory<T>
): TypedMoneyAddressRegistry {
if (pMappings.put(protocol, f) != null) {
logger.warn {
"Overwriting existing MoneyAddressRegistry entry [protocol=$protocol]"
}
}
return this
}

fun clear() {
cpMappings.clear()
pMappings.clear()
}

private val cpMappings = mutableMapOf<Pair<Currency, Protocol>, TypedMoneyAddressFactory<*>>()
private val pMappings = mutableMapOf<Protocol, TypedMoneyAddressFactory<*>>()

fun toTypedMoneyAddress(ma: UntypedMoneyAddress): TypedMoneyAddress<*> =
cpMappings[Pair(ma.currency.asCurrency(), ma.protocol.asProtocol())]?.invoke(ma)
?: pMappings[ma.protocol.asProtocol()]?.invoke(ma)
?: UnrecognizedMoneyAddress(ma.pss, ma.currency, ma.protocol, ma.id)

private val logger = KotlinLogging.logger {}

companion object {
val defaultTypedMoneyAddressRegistry: TypedMoneyAddressRegistry = TypedMoneyAddressRegistry()

init {
BtcLightningAddress.register(defaultTypedMoneyAddressRegistry)
BtcOnChainAddress.register(defaultTypedMoneyAddressRegistry)
MobileMoneyAddress.register(defaultTypedMoneyAddressRegistry)
}

fun UntypedMoneyAddress.toTypedMoneyAddress(typedMoneyAddressRegistry: TypedMoneyAddressRegistry = defaultTypedMoneyAddressRegistry): TypedMoneyAddress<*> =
typedMoneyAddressRegistry.toTypedMoneyAddress(this)
}
}
Loading
Loading