mirror of
https://github.com/corda/corda.git
synced 2025-02-01 00:45:59 +00:00
Merged in misc-simulation-improvements (pull request #53)
Misc simulation improvements
This commit is contained in:
commit
1da7b4bf01
@ -83,7 +83,7 @@ class CommercialPaper : Contract {
|
||||
// Here, we match acceptable timestamp authorities by name. The list of acceptable TSAs (oracles) must be
|
||||
// hard coded into the contract because otherwise we could fail to gain consensus, if nodes disagree about
|
||||
// who or what is a trusted authority.
|
||||
val timestamp: TimestampCommand? = tx.commands.getTimestampByName("Mock Company 0", "Bank of Zurich")
|
||||
val timestamp: TimestampCommand? = tx.commands.getTimestampByName("Mock Company 0", "Bank A")
|
||||
|
||||
for (group in groups) {
|
||||
when (command.value) {
|
||||
|
@ -52,12 +52,12 @@ abstract class PaymentEvent(date: LocalDate) : Event(date) {
|
||||
* For the floating leg, the rate refers to a reference rate which is to be "fixed" at a point in the future.
|
||||
*/
|
||||
abstract class RatePaymentEvent(date: LocalDate,
|
||||
val accrualStartDate: LocalDate,
|
||||
val accrualEndDate: LocalDate,
|
||||
val dayCountBasisDay: DayCountBasisDay,
|
||||
val dayCountBasisYear: DayCountBasisYear,
|
||||
val notional: Amount,
|
||||
val rate: Rate) : PaymentEvent(date) {
|
||||
val accrualStartDate: LocalDate,
|
||||
val accrualEndDate: LocalDate,
|
||||
val dayCountBasisDay: DayCountBasisDay,
|
||||
val dayCountBasisYear: DayCountBasisYear,
|
||||
val notional: Amount,
|
||||
val rate: Rate) : PaymentEvent(date) {
|
||||
companion object {
|
||||
val CSVHeader = "AccrualStartDate,AccrualEndDate,DayCountFactor,Days,Date,Ccy,Notional,Rate,Flow"
|
||||
}
|
||||
@ -70,7 +70,7 @@ abstract class RatePaymentEvent(date: LocalDate,
|
||||
dayCountCalculator(accrualStartDate, accrualEndDate, dayCountBasisYear, dayCountBasisDay)
|
||||
|
||||
val dayCountFactor: BigDecimal get() =
|
||||
// TODO : Fix below (use daycount convention for division)
|
||||
// TODO : Fix below (use daycount convention for division)
|
||||
(BigDecimal(days).divide(BigDecimal(360.0), 8, RoundingMode.HALF_UP)).setScale(4, RoundingMode.HALF_UP)
|
||||
|
||||
open fun asCSV(): String = "$accrualStartDate,$accrualEndDate,$dayCountFactor,$days,$date,${notional.currency},${notional},$rate,$flow"
|
||||
@ -137,12 +137,6 @@ class FloatingRatePaymentEvent(date: LocalDate,
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Don't try and use a rate that isn't ready yet.
|
||||
*/
|
||||
class DataNotReadyException : Exception()
|
||||
|
||||
|
||||
/**
|
||||
* The Interest Rate Swap class. For a quick overview of what an IRS is, see here - http://www.pimco.co.uk/EN/Education/Pages/InterestRateSwapsBasics1-08.aspx (no endorsement)
|
||||
* This contract has 4 significant data classes within it, the "Common", "Calculation", "FixedLeg" and "FloatingLeg"
|
||||
@ -181,7 +175,7 @@ class InterestRateSwap() : Contract {
|
||||
* data that will changed from state to state (Recall that the design insists that everything is immutable, so we actually
|
||||
* copy / update for each transition)
|
||||
*/
|
||||
data class Calculation (
|
||||
data class Calculation(
|
||||
val expression: Expression,
|
||||
val floatingLegPaymentSchedule: Map<LocalDate, FloatingRatePaymentEvent>,
|
||||
val fixedLegPaymentSchedule: Map<LocalDate, FixedRatePaymentEvent>
|
||||
@ -240,7 +234,7 @@ class InterestRateSwap() : Contract {
|
||||
"PaymentRule=$paymentRule,PaymentDelay=$paymentDelay,PaymentCalendar=$paymentCalendar,InterestPeriodAdjustment=$interestPeriodAdjustment"
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean{
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other?.javaClass != javaClass) return false
|
||||
|
||||
@ -263,7 +257,7 @@ class InterestRateSwap() : Contract {
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int{
|
||||
override fun hashCode(): Int {
|
||||
var result = notional.hashCode()
|
||||
result += 31 * result + paymentFrequency.hashCode()
|
||||
result += 31 * result + effectiveDate.hashCode()
|
||||
@ -306,7 +300,7 @@ class InterestRateSwap() : Contract {
|
||||
override fun toString(): String = "FixedLeg(Payer=$fixedRatePayer," + super.toString() + ",fixedRate=$fixedRate," +
|
||||
"rollConvention=$rollConvention"
|
||||
|
||||
override fun equals(other: Any?): Boolean{
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other?.javaClass != javaClass) return false
|
||||
if (!super.equals(other)) return false
|
||||
@ -320,7 +314,7 @@ class InterestRateSwap() : Contract {
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int{
|
||||
override fun hashCode(): Int {
|
||||
var result = super.hashCode()
|
||||
result += 31 * result + fixedRatePayer.hashCode()
|
||||
result += 31 * result + fixedRate.hashCode()
|
||||
@ -362,7 +356,7 @@ class InterestRateSwap() : Contract {
|
||||
"FixingPeriond=$fixingPeriod,ResetRule=$resetRule,FixingsPerPayment=$fixingsPerPayment,FixingCalendar=$fixingCalendar," +
|
||||
"Index=$index,IndexSource=$indexSource,IndexTenor=$indexTenor"
|
||||
|
||||
override fun equals(other: Any?): Boolean{
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other?.javaClass != javaClass) return false
|
||||
if (!super.equals(other)) return false
|
||||
@ -384,7 +378,7 @@ class InterestRateSwap() : Contract {
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int{
|
||||
override fun hashCode(): Int {
|
||||
var result = super.hashCode()
|
||||
result += 31 * result + floatingRatePayer.hashCode()
|
||||
result += 31 * result + rollConvention.hashCode()
|
||||
@ -407,7 +401,7 @@ class InterestRateSwap() : Contract {
|
||||
*/
|
||||
override fun verify(tx: TransactionForVerification) {
|
||||
val command = tx.commands.requireSingleCommand<InterestRateSwap.Commands>()
|
||||
val time = tx.commands.getTimestampByName("Mock Company 0", "Bank A")?.midpoint
|
||||
val time = tx.commands.getTimestampByName("Mock Company 0", "European Timestamping Service", "Bank A")?.midpoint
|
||||
if (time == null) throw IllegalArgumentException("must be timestamped")
|
||||
|
||||
val irs = tx.outStates.filterIsInstance<InterestRateSwap.State>().single()
|
||||
@ -473,11 +467,11 @@ class InterestRateSwap() : Contract {
|
||||
|
||||
override fun withPublicKey(before: Party, after: PublicKey): State {
|
||||
val newParty = Party(before.name, after)
|
||||
if(before == fixedLeg.fixedRatePayer) {
|
||||
if (before == fixedLeg.fixedRatePayer) {
|
||||
val deal = copy()
|
||||
deal.fixedLeg.fixedRatePayer = newParty
|
||||
return deal
|
||||
} else if(before == floatingLeg.floatingRatePayer) {
|
||||
} else if (before == floatingLeg.floatingRatePayer) {
|
||||
val deal = copy()
|
||||
deal.floatingLeg.floatingRatePayer = newParty
|
||||
return deal
|
||||
@ -494,7 +488,7 @@ class InterestRateSwap() : Contract {
|
||||
|
||||
override fun nextFixingOf(): FixOf? {
|
||||
val date = calculation.nextFixingDate()
|
||||
return if (date==null) null else {
|
||||
return if (date == null) null else {
|
||||
val fixingEvent = calculation.getFixing(date)
|
||||
val oracleRate = fixingEvent.rate as ReferenceRate
|
||||
FixOf(oracleRate.name, date, oracleRate.tenor)
|
||||
|
@ -18,4 +18,5 @@ import java.security.PublicKey
|
||||
*/
|
||||
interface IdentityService {
|
||||
fun partyFromKey(key: PublicKey): Party?
|
||||
fun partyFromName(name: String): Party?
|
||||
}
|
||||
|
@ -42,13 +42,13 @@ import java.util.*
|
||||
*/
|
||||
class ProgressTracker(vararg steps: Step) {
|
||||
sealed class Change {
|
||||
class Position(val newStep: Step) : Change() {
|
||||
class Position(val tracker: ProgressTracker, val newStep: Step) : Change() {
|
||||
override fun toString() = newStep.label
|
||||
}
|
||||
class Rendering(val ofStep: Step) : Change() {
|
||||
class Rendering(val tracker: ProgressTracker, val ofStep: Step) : Change() {
|
||||
override fun toString() = ofStep.label
|
||||
}
|
||||
class Structural(val parent: Step) : Change() {
|
||||
class Structural(val tracker: ProgressTracker, val parent: Step) : Change() {
|
||||
override fun toString() = "Structural step change in child of ${parent.label}"
|
||||
}
|
||||
}
|
||||
@ -59,13 +59,13 @@ class ProgressTracker(vararg steps: Step) {
|
||||
}
|
||||
|
||||
/** This class makes it easier to relabel a step on the fly, to provide transient information. */
|
||||
open class RelabelableStep(currentLabel: String) : Step(currentLabel) {
|
||||
open inner class RelabelableStep(currentLabel: String) : Step(currentLabel) {
|
||||
override val changes = BehaviorSubject.create<Change>()
|
||||
|
||||
var currentLabel: String = currentLabel
|
||||
set(value) {
|
||||
field = value
|
||||
changes.onNext(ProgressTracker.Change.Rendering(this))
|
||||
changes.onNext(ProgressTracker.Change.Rendering(this@ProgressTracker, this@RelabelableStep))
|
||||
}
|
||||
|
||||
override val label: String get() = currentLabel
|
||||
@ -109,7 +109,7 @@ class ProgressTracker(vararg steps: Step) {
|
||||
|
||||
curChangeSubscription?.unsubscribe()
|
||||
stepIndex = index
|
||||
_changes.onNext(Change.Position(steps[index]))
|
||||
_changes.onNext(Change.Position(this, steps[index]))
|
||||
curChangeSubscription = currentStep.changes.subscribe { _changes.onNext(it) }
|
||||
|
||||
if (currentStep == DONE) _changes.onCompleted()
|
||||
@ -128,18 +128,33 @@ class ProgressTracker(vararg steps: Step) {
|
||||
override fun put(key: Step, value: ProgressTracker): ProgressTracker? {
|
||||
val r = super.put(key, value)
|
||||
childSubscriptions[value] = value.changes.subscribe({ _changes.onNext(it) }, { _changes.onError(it) })
|
||||
_changes.onNext(Change.Structural(key))
|
||||
value.parent = this@ProgressTracker
|
||||
_changes.onNext(Change.Structural(this@ProgressTracker, key))
|
||||
return r
|
||||
}
|
||||
|
||||
override fun remove(key: Step): ProgressTracker? {
|
||||
if (containsKey(key))
|
||||
childSubscriptions[this[key]]?.let { it.unsubscribe(); childSubscriptions.remove(this[key]) }
|
||||
_changes.onNext(Change.Structural(key))
|
||||
val tracker = this[key]
|
||||
if (tracker != null) {
|
||||
tracker.parent = null
|
||||
childSubscriptions[tracker]?.let { it.unsubscribe(); childSubscriptions.remove(tracker) }
|
||||
}
|
||||
_changes.onNext(Change.Structural(this@ProgressTracker, key))
|
||||
return super.remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
/** The parent of this tracker: set automatically by the parent when a tracker is added as a child */
|
||||
var parent: ProgressTracker? = null
|
||||
|
||||
/** Walks up the tree to find the top level tracker. If this is the top level tracker, returns 'this' */
|
||||
val topLevelTracker: ProgressTracker
|
||||
get() {
|
||||
var cursor: ProgressTracker = this
|
||||
while (cursor.parent != null) cursor = cursor.parent!!
|
||||
return cursor
|
||||
}
|
||||
|
||||
private val childSubscriptions = HashMap<ProgressTracker, Subscription>()
|
||||
|
||||
private fun _allSteps(level: Int = 0): List<Pair<Int, Step>> {
|
||||
|
@ -12,14 +12,14 @@ fi
|
||||
if [[ "$mode" == "buyer" ]]; then
|
||||
if [ ! -d buyer ]; then
|
||||
mkdir buyer
|
||||
echo "myLegalName = Bank of Zurich" >buyer/config
|
||||
echo "myLegalName = Bank A" >buyer/config
|
||||
fi
|
||||
|
||||
build/install/r3prototyping/bin/r3prototyping --dir=buyer --service-fake-trades --network-address=localhost
|
||||
elif [[ "$mode" == "seller" ]]; then
|
||||
if [ ! -d seller ]; then
|
||||
mkdir seller
|
||||
echo "myLegalName = Bank of London" >seller/config
|
||||
echo "myLegalName = Bank B" >seller/config
|
||||
fi
|
||||
|
||||
build/install/r3prototyping/bin/r3prototyping --dir=seller --fake-trade-with=localhost --network-address=localhost:31340 --timestamper-identity-file=buyer/identity-public --timestamper-address=localhost
|
||||
|
@ -1,21 +1,8 @@
|
||||
package api
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator
|
||||
import com.fasterxml.jackson.core.JsonParseException
|
||||
import com.fasterxml.jackson.core.JsonParser
|
||||
import com.fasterxml.jackson.core.JsonToken
|
||||
import com.fasterxml.jackson.databind.*
|
||||
import com.fasterxml.jackson.databind.deser.std.NumberDeserializers
|
||||
import com.fasterxml.jackson.databind.deser.std.StringArrayDeserializer
|
||||
import com.fasterxml.jackson.databind.module.SimpleModule
|
||||
import com.fasterxml.jackson.module.kotlin.KotlinModule
|
||||
import core.BusinessCalendar
|
||||
import core.Party
|
||||
import core.crypto.SecureHash
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import core.node.services.ServiceHub
|
||||
import java.math.BigDecimal
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import core.utilities.JsonSupport
|
||||
import javax.ws.rs.ext.ContextResolver
|
||||
import javax.ws.rs.ext.Provider
|
||||
|
||||
@ -25,119 +12,6 @@ import javax.ws.rs.ext.Provider
|
||||
*/
|
||||
@Provider
|
||||
class Config(val services: ServiceHub): ContextResolver<ObjectMapper> {
|
||||
|
||||
val defaultObjectMapper = createDefaultMapper(services)
|
||||
|
||||
override fun getContext(type: java.lang.Class<*>): ObjectMapper {
|
||||
return defaultObjectMapper
|
||||
}
|
||||
|
||||
class ServiceHubObjectMapper(var serviceHub: ServiceHub): ObjectMapper() {
|
||||
|
||||
}
|
||||
|
||||
companion object {
|
||||
private fun createDefaultMapper(services: ServiceHub): ObjectMapper {
|
||||
val mapper = ServiceHubObjectMapper(services)
|
||||
mapper.enable(SerializationFeature.INDENT_OUTPUT);
|
||||
mapper.enable(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY)
|
||||
mapper.enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS)
|
||||
|
||||
val timeModule = SimpleModule("java.time")
|
||||
timeModule.addSerializer(LocalDate::class.java, ToStringSerializer)
|
||||
timeModule.addDeserializer(LocalDate::class.java, LocalDateDeserializer)
|
||||
timeModule.addKeyDeserializer(LocalDate::class.java, LocalDateKeyDeserializer)
|
||||
timeModule.addSerializer(LocalDateTime::class.java, ToStringSerializer)
|
||||
|
||||
val cordaModule = SimpleModule("core")
|
||||
cordaModule.addSerializer(Party::class.java, PartySerializer)
|
||||
cordaModule.addDeserializer(Party::class.java, PartyDeserializer)
|
||||
cordaModule.addSerializer(BigDecimal::class.java, ToStringSerializer)
|
||||
cordaModule.addDeserializer(BigDecimal::class.java, NumberDeserializers.BigDecimalDeserializer())
|
||||
cordaModule.addSerializer(SecureHash::class.java, SecureHashSerializer)
|
||||
// It's slightly remarkable, but apparently Jackson works out that this is the only possibility
|
||||
// for a SecureHash at the moment and tries to use SHA256 directly even though we only give it SecureHash
|
||||
cordaModule.addDeserializer(SecureHash.SHA256::class.java, SecureHashDeserializer())
|
||||
cordaModule.addDeserializer(BusinessCalendar::class.java, CalendarDeserializer)
|
||||
|
||||
mapper.registerModule(timeModule)
|
||||
mapper.registerModule(cordaModule)
|
||||
mapper.registerModule(KotlinModule())
|
||||
return mapper
|
||||
}
|
||||
}
|
||||
|
||||
object ToStringSerializer: JsonSerializer<Any>() {
|
||||
override fun serialize(obj: Any, generator: JsonGenerator, provider: SerializerProvider) {
|
||||
generator.writeString(obj.toString())
|
||||
}
|
||||
}
|
||||
|
||||
object LocalDateDeserializer: JsonDeserializer<LocalDate>() {
|
||||
override fun deserialize(parser: JsonParser, context: DeserializationContext): LocalDate {
|
||||
return try {
|
||||
LocalDate.parse(parser.text)
|
||||
} catch (e: Exception) {
|
||||
throw JsonParseException("Invalid LocalDate ${parser.text}: ${e.message}", parser.currentLocation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object LocalDateKeyDeserializer: KeyDeserializer() {
|
||||
override fun deserializeKey(text: String, p1: DeserializationContext): Any? {
|
||||
return LocalDate.parse(text)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
object PartySerializer: JsonSerializer<Party>() {
|
||||
override fun serialize(obj: Party, generator: JsonGenerator, provider: SerializerProvider) {
|
||||
generator.writeString(obj.name)
|
||||
}
|
||||
}
|
||||
|
||||
object PartyDeserializer: JsonDeserializer<Party>() {
|
||||
override fun deserialize(parser: JsonParser, context: DeserializationContext): Party {
|
||||
if(parser.currentToken == JsonToken.FIELD_NAME) {
|
||||
parser.nextToken()
|
||||
}
|
||||
val mapper = parser.codec as ServiceHubObjectMapper
|
||||
// TODO this needs to use some industry identifier(s) not just these human readable names
|
||||
val nodeForPartyName = mapper.serviceHub.networkMapCache.nodeForPartyName(parser.text) ?: throw JsonParseException("Could not find a Party with name: ${parser.text}", parser.currentLocation)
|
||||
return nodeForPartyName.identity
|
||||
}
|
||||
}
|
||||
|
||||
object SecureHashSerializer: JsonSerializer<SecureHash>() {
|
||||
override fun serialize(obj: SecureHash, generator: JsonGenerator, provider: SerializerProvider) {
|
||||
generator.writeString(obj.toString())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Implemented as a class so that we can instantiate for T
|
||||
*/
|
||||
class SecureHashDeserializer<T : SecureHash>: JsonDeserializer<T>() {
|
||||
override fun deserialize(parser: JsonParser, context: DeserializationContext): T {
|
||||
if(parser.currentToken == JsonToken.FIELD_NAME) {
|
||||
parser.nextToken()
|
||||
}
|
||||
return try {
|
||||
return SecureHash.parse(parser.text) as T
|
||||
} catch (e: Exception) {
|
||||
throw JsonParseException("Invalid hash ${parser.text}: ${e.message}", parser.currentLocation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object CalendarDeserializer: JsonDeserializer<BusinessCalendar>() {
|
||||
override fun deserialize(parser: JsonParser, context: DeserializationContext): BusinessCalendar {
|
||||
return try {
|
||||
val array = StringArrayDeserializer.instance.deserialize(parser, context)
|
||||
BusinessCalendar.getInstance(*array)
|
||||
} catch (e: Exception) {
|
||||
throw JsonParseException("Invalid calendar(s) ${parser.text}: ${e.message}", parser.currentLocation)
|
||||
}
|
||||
}
|
||||
}
|
||||
val defaultObjectMapper = JsonSupport.createDefaultMapper(services.identityService)
|
||||
override fun getContext(type: java.lang.Class<*>) = defaultObjectMapper
|
||||
}
|
@ -80,6 +80,8 @@ class StateMachineManager(val serviceHub: ServiceHub, val runInThread: Executor)
|
||||
// checkpointing when unit tests are run inside Gradle. The right fix is probably to stop Quasar's
|
||||
// bit-too-clever-for-its-own-good ThreadLocal serialisation trick. It already wasted far more time than it can
|
||||
// ever recover.
|
||||
//
|
||||
// TODO: Remove this now that TLS serialisation is fixed.
|
||||
val checkpointing: Boolean get() = !System.err.javaClass.name.contains("LinePerThreadBufferingOutputStream")
|
||||
|
||||
/** Returns a list of all state machines executing the given protocol logic at the top level (subprotocols do not count) */
|
||||
|
@ -36,6 +36,7 @@ import java.nio.file.FileAlreadyExistsException
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.security.KeyPair
|
||||
import java.security.PublicKey
|
||||
import java.time.Clock
|
||||
import java.util.*
|
||||
import java.util.concurrent.Executors
|
||||
@ -112,9 +113,19 @@ abstract class AbstractNode(val dir: Path, val configuration: NodeConfiguration,
|
||||
smm = StateMachineManager(services, serverThread)
|
||||
wallet = NodeWalletService(services)
|
||||
keyManagement = E2ETestKeyManagementService()
|
||||
makeInterestRateOracleService()
|
||||
makeInterestRatesOracleService()
|
||||
api = APIServerImpl(this)
|
||||
makeTimestampingService(timestamperAddress)
|
||||
identity = makeIdentityService()
|
||||
|
||||
// This object doesn't need to be referenced from this class because it registers handlers on the network
|
||||
// service and so that keeps it from being collected.
|
||||
DataVendingService(net, storage)
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
private fun makeTimestampingService(timestamperAddress: NodeInfo?) {
|
||||
// Insert a network map entry for the timestamper: this is all temp scaffolding and will go away. If we are
|
||||
// given the details, the timestamping node is somewhere else. Otherwise, we do our own timestamping.
|
||||
val tsid = if (timestamperAddress != null) {
|
||||
@ -125,30 +136,39 @@ abstract class AbstractNode(val dir: Path, val configuration: NodeConfiguration,
|
||||
NodeInfo(net.myAddress, storage.myLegalIdentity)
|
||||
}
|
||||
(services.networkMapCache as MockNetworkMapCache).timestampingNodes.add(tsid)
|
||||
|
||||
identity = makeIdentityService()
|
||||
|
||||
// This object doesn't need to be referenced from this class because it registers handlers on the network
|
||||
// service and so that keeps it from being collected.
|
||||
DataVendingService(net, storage)
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
protected fun makeInterestRateOracleService() {
|
||||
// Constructing the service registers message handlers that ensure the service won't be garbage collected.
|
||||
lateinit var interestRatesService: NodeInterestRates.Service
|
||||
|
||||
open protected fun makeInterestRatesOracleService() {
|
||||
// TODO: Once the service has data, automatically register with the network map service (once built).
|
||||
_servicesThatAcceptUploads += NodeInterestRates.Service(this)
|
||||
interestRatesService = NodeInterestRates.Service(this)
|
||||
_servicesThatAcceptUploads += interestRatesService
|
||||
}
|
||||
|
||||
protected open fun makeIdentityService(): IdentityService {
|
||||
// We don't have any identity infrastructure right now, so we just throw together the only two identities we
|
||||
// know about: our own, and the identity of the remote timestamper node (if any).
|
||||
val knownIdentities = if (timestamperAddress != null)
|
||||
// We don't have any identity infrastructure right now, so we just throw together the only identities we
|
||||
// know about: our own, the identity of the remote timestamper node (if any), plus whatever is in the
|
||||
// network map.
|
||||
//
|
||||
// TODO: All this will be replaced soon enough.
|
||||
val fixedIdentities = if (timestamperAddress != null)
|
||||
listOf(storage.myLegalIdentity, timestamperAddress.identity)
|
||||
else
|
||||
listOf(storage.myLegalIdentity)
|
||||
return FixedIdentityService(knownIdentities)
|
||||
|
||||
return object : IdentityService {
|
||||
private val identities: List<Party> get() = fixedIdentities + services.networkMapCache.partyNodes.map { it.identity }
|
||||
private val keyToParties: Map<PublicKey, Party> get() = identities.associateBy { it.owningKey }
|
||||
private val nameToParties: Map<String, Party> get() = identities.associateBy { it.name }
|
||||
|
||||
override fun partyFromKey(key: PublicKey): Party? = keyToParties[key]
|
||||
override fun partyFromName(name: String): Party? = nameToParties[name]
|
||||
|
||||
override fun toString(): String {
|
||||
return identities.joinToString { it.name }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
open fun stop() {
|
||||
|
@ -8,14 +8,23 @@
|
||||
|
||||
package core.node.services
|
||||
|
||||
import core.node.services.IdentityService
|
||||
import core.Party
|
||||
import java.security.PublicKey
|
||||
import javax.annotation.concurrent.ThreadSafe
|
||||
|
||||
/**
|
||||
* Scaffolding: a dummy identity service that just expects to have identities loaded off disk or found elsewhere.
|
||||
* This class allows the provided list of identities to be mutated after construction, so it takes the list lock
|
||||
* when doing lookups and recalculates the mapping each time. The ability to change the list is used by the
|
||||
* MockNetwork code.
|
||||
*/
|
||||
class FixedIdentityService(private val identities: List<Party>) : IdentityService {
|
||||
private val keyToParties: Map<PublicKey, Party> get() = identities.associateBy { it.owningKey }
|
||||
@ThreadSafe
|
||||
class FixedIdentityService(val identities: List<Party>) : IdentityService {
|
||||
private val keyToParties: Map<PublicKey, Party>
|
||||
get() = synchronized(identities) { identities.associateBy { it.owningKey } }
|
||||
private val nameToParties: Map<String, Party>
|
||||
get() = synchronized(identities) { identities.associateBy { it.name } }
|
||||
|
||||
override fun partyFromKey(key: PublicKey): Party? = keyToParties[key]
|
||||
override fun partyFromName(name: String): Party? = nameToParties[name]
|
||||
}
|
@ -32,6 +32,7 @@ interface NetworkMapCache {
|
||||
val timestampingNodes: List<NodeInfo>
|
||||
val ratesOracleNodes: List<NodeInfo>
|
||||
val partyNodes: List<NodeInfo>
|
||||
val regulators: List<NodeInfo>
|
||||
|
||||
fun nodeForPartyName(name: String): NodeInfo? = partyNodes.singleOrNull { it.identity.name == name }
|
||||
}
|
||||
@ -43,6 +44,7 @@ class MockNetworkMapCache : NetworkMapCache {
|
||||
override val timestampingNodes = Collections.synchronizedList(ArrayList<NodeInfo>())
|
||||
override val ratesOracleNodes = Collections.synchronizedList(ArrayList<NodeInfo>())
|
||||
override val partyNodes = Collections.synchronizedList(ArrayList<NodeInfo>())
|
||||
override val regulators = Collections.synchronizedList(ArrayList<NodeInfo>())
|
||||
|
||||
init {
|
||||
partyNodes.add(NodeInfo(MockAddress("bankC:8080"), Party("Bank C", DummyPublicKey("Bank C"))))
|
||||
|
@ -95,6 +95,18 @@ interface WalletService {
|
||||
fun notify(tx: WireTransaction): Wallet = notifyAll(listOf(tx))
|
||||
}
|
||||
|
||||
// TODO: Document this
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
inline fun <reified T : LinearState> WalletService.linearHeadsOfType(): Map<SecureHash, StateAndRef<T>> {
|
||||
return linearHeads.mapNotNull {
|
||||
val s = it.value.state
|
||||
if (s is T)
|
||||
Pair(it.key, it.value as StateAndRef<T>)
|
||||
else
|
||||
null
|
||||
}.toMap()
|
||||
}
|
||||
|
||||
/**
|
||||
* The KMS is responsible for storing and using private keys to sign things. An implementation of this may, for example,
|
||||
* call out to a hardware security module that enforces various auditing and frequency-of-use requirements.
|
||||
|
141
src/main/kotlin/core/testing/IRSSimulation.kt
Normal file
141
src/main/kotlin/core/testing/IRSSimulation.kt
Normal file
@ -0,0 +1,141 @@
|
||||
/*
|
||||
* Copyright 2015 Distributed Ledger Group LLC. Distributed as Licensed Company IP to DLG Group Members
|
||||
* pursuant to the August 7, 2015 Advisory Services Agreement and subject to the Company IP License terms
|
||||
* set forth therein.
|
||||
*
|
||||
* All other rights reserved.
|
||||
*/
|
||||
|
||||
package core.testing
|
||||
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import com.google.common.util.concurrent.Futures
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import com.google.common.util.concurrent.SettableFuture
|
||||
import contracts.InterestRateSwap
|
||||
import core.*
|
||||
import core.crypto.SecureHash
|
||||
import core.node.services.FixedIdentityService
|
||||
import core.node.services.linearHeadsOfType
|
||||
import core.utilities.JsonSupport
|
||||
import protocols.TwoPartyDealProtocol
|
||||
import java.time.LocalDate
|
||||
import java.util.*
|
||||
|
||||
|
||||
/**
|
||||
* A simulation in which banks execute interest rate swaps with each other, including the fixing events.
|
||||
*/
|
||||
class IRSSimulation(runAsync: Boolean, latencyInjector: InMemoryMessagingNetwork.LatencyCalculator?) : Simulation(runAsync, latencyInjector) {
|
||||
val om = JsonSupport.createDefaultMapper(FixedIdentityService(network.identities))
|
||||
|
||||
init {
|
||||
currentDay = LocalDate.of(2016, 3, 10) // Should be 12th but the actual first fixing date gets rolled backwards.
|
||||
}
|
||||
|
||||
private val executeOnNextIteration = Collections.synchronizedList(LinkedList<() -> Unit>())
|
||||
|
||||
override fun start() {
|
||||
startIRSDealBetween(0, 1).success {
|
||||
// Next iteration is a pause.
|
||||
executeOnNextIteration.add {}
|
||||
executeOnNextIteration.add {
|
||||
// Keep fixing until there's no more left to do.
|
||||
doNextFixing(0, 1)?.addListener(object : Runnable {
|
||||
override fun run() {
|
||||
// Pause for an iteration.
|
||||
executeOnNextIteration.add {}
|
||||
executeOnNextIteration.add {
|
||||
doNextFixing(0, 1)?.addListener(this, RunOnCallerThread)
|
||||
}
|
||||
}
|
||||
}, RunOnCallerThread)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun doNextFixing(i: Int, j: Int): ListenableFuture<*>? {
|
||||
println("Doing a fixing between $i and $j")
|
||||
val node1: SimulatedNode = banks[i]
|
||||
val node2: SimulatedNode = banks[j]
|
||||
|
||||
val sessionID = random63BitValue()
|
||||
val swaps: Map<SecureHash, StateAndRef<InterestRateSwap.State>> = node1.services.walletService.linearHeadsOfType<InterestRateSwap.State>()
|
||||
val theDealRef: StateAndRef<InterestRateSwap.State> = swaps.values.single()
|
||||
|
||||
// Do we have any more days left in this deal's lifetime? If not, return.
|
||||
val nextFixingDate = theDealRef.state.calculation.nextFixingDate() ?: return null
|
||||
extraNodeLabels[node1] = "Fixing event on $nextFixingDate"
|
||||
extraNodeLabels[node2] = "Fixing event on $nextFixingDate"
|
||||
|
||||
// For some reason the first fix is always before the effective date.
|
||||
if (nextFixingDate > currentDay)
|
||||
currentDay = nextFixingDate
|
||||
|
||||
val sideA = TwoPartyDealProtocol.Floater(node2.net.myAddress, sessionID, timestamper.info,
|
||||
theDealRef, node1.services.keyManagementService.freshKey(), sessionID)
|
||||
val sideB = TwoPartyDealProtocol.Fixer(node1.net.myAddress, timestamper.info.identity,
|
||||
theDealRef, sessionID)
|
||||
|
||||
linkConsensus(listOf(node1, node2, regulators[0]), sideB)
|
||||
linkProtocolProgress(node1, sideA)
|
||||
linkProtocolProgress(node2, sideB)
|
||||
|
||||
// We have to start the protocols in separate iterations, as adding to the SMM effectively 'iterates' that node
|
||||
// in the simulation, so if we don't do this then the two sides seem to act simultaneously.
|
||||
|
||||
val retFuture = SettableFuture.create<Any>()
|
||||
val futA = node1.smm.add("floater", sideA)
|
||||
executeOnNextIteration += {
|
||||
val futB = node2.smm.add("fixer", sideB)
|
||||
Futures.allAsList(futA, futB).then {
|
||||
retFuture.set(null)
|
||||
}
|
||||
}
|
||||
return retFuture
|
||||
}
|
||||
|
||||
private fun startIRSDealBetween(i: Int, j: Int): ListenableFuture<SignedTransaction> {
|
||||
val node1: SimulatedNode = banks[i]
|
||||
val node2: SimulatedNode = banks[j]
|
||||
|
||||
extraNodeLabels[node1] = "Setting up deal"
|
||||
extraNodeLabels[node2] = "Setting up deal"
|
||||
|
||||
// We load the IRS afresh each time because the leg parts of the structure aren't data classes so they don't
|
||||
// have the convenient copy() method that'd let us make small adjustments. Instead they're partly mutable.
|
||||
// TODO: We should revisit this in post-Excalibur cleanup and fix, e.g. by introducing an interface.
|
||||
val irs = om.readValue<InterestRateSwap.State>(javaClass.getResource("trade.json"))
|
||||
irs.fixedLeg.fixedRatePayer = node1.info.identity
|
||||
irs.floatingLeg.floatingRatePayer = node2.info.identity
|
||||
|
||||
if (irs.fixedLeg.effectiveDate < irs.floatingLeg.effectiveDate)
|
||||
currentDay = irs.fixedLeg.effectiveDate
|
||||
else
|
||||
currentDay = irs.floatingLeg.effectiveDate
|
||||
|
||||
val sessionID = random63BitValue()
|
||||
|
||||
val instigator = TwoPartyDealProtocol.Instigator(node2.net.myAddress, timestamper.info,
|
||||
irs, node1.services.keyManagementService.freshKey(), sessionID)
|
||||
val acceptor = TwoPartyDealProtocol.Acceptor(node1.net.myAddress, timestamper.info.identity,
|
||||
irs, sessionID)
|
||||
|
||||
// TODO: Eliminate the need for linkProtocolProgress
|
||||
linkConsensus(listOf(node1, node2, regulators[0]), acceptor)
|
||||
linkProtocolProgress(node1, instigator)
|
||||
linkProtocolProgress(node2, acceptor)
|
||||
|
||||
val instigatorFuture: ListenableFuture<SignedTransaction> = node1.smm.add("instigator", instigator)
|
||||
|
||||
return Futures.transformAsync(Futures.allAsList(instigatorFuture, node2.smm.add("acceptor", acceptor))) {
|
||||
instigatorFuture
|
||||
}
|
||||
}
|
||||
|
||||
override fun iterate() {
|
||||
if (executeOnNextIteration.isNotEmpty())
|
||||
executeOnNextIteration.removeAt(0)()
|
||||
super.iterate()
|
||||
}
|
||||
}
|
@ -93,6 +93,8 @@ class MockNetwork(private val threadPerNode: Boolean = false,
|
||||
mockNet.identities.add(storage.myLegalIdentity)
|
||||
return this
|
||||
}
|
||||
|
||||
val place: PhysicalLocation get() = info.physicalLocation!!
|
||||
}
|
||||
|
||||
/** Returns a started node, optionally created by the passed factory method */
|
||||
|
213
src/main/kotlin/core/testing/Simulation.kt
Normal file
213
src/main/kotlin/core/testing/Simulation.kt
Normal file
@ -0,0 +1,213 @@
|
||||
/*
|
||||
* Copyright 2015 Distributed Ledger Group LLC. Distributed as Licensed Company IP to DLG Group Members
|
||||
* pursuant to the August 7, 2015 Advisory Services Agreement and subject to the Company IP License terms
|
||||
* set forth therein.
|
||||
*
|
||||
* All other rights reserved.
|
||||
*/
|
||||
|
||||
package core.testing
|
||||
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import core.node.NodeConfiguration
|
||||
import core.node.services.CityDatabase
|
||||
import core.node.services.MockNetworkMapCache
|
||||
import core.node.services.NodeInfo
|
||||
import core.node.services.PhysicalLocation
|
||||
import core.protocols.ProtocolLogic
|
||||
import core.then
|
||||
import core.utilities.ProgressTracker
|
||||
import rx.Observable
|
||||
import rx.subjects.PublishSubject
|
||||
import java.nio.file.Path
|
||||
import java.time.LocalDate
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Base class for network simulations that are based on the unit test / mock environment.
|
||||
*
|
||||
* Sets up some nodes that can run protocols between each other, and exposes their progress trackers. Provides banks
|
||||
* in a few cities around the world.
|
||||
*/
|
||||
abstract class Simulation(val runAsync: Boolean,
|
||||
val latencyInjector: InMemoryMessagingNetwork.LatencyCalculator?) {
|
||||
init {
|
||||
if (!runAsync && latencyInjector != null)
|
||||
throw IllegalArgumentException("The latency injector is only useful when using manual pumping.")
|
||||
}
|
||||
|
||||
val bankLocations = listOf("London", "Frankfurt", "Rome")
|
||||
|
||||
// This puts together a mock network of SimulatedNodes.
|
||||
|
||||
open class SimulatedNode(dir: Path, config: NodeConfiguration, mockNet: MockNetwork,
|
||||
withTimestamper: NodeInfo?) : MockNetwork.MockNode(dir, config, mockNet, withTimestamper) {
|
||||
override fun findMyLocation(): PhysicalLocation? = CityDatabase[configuration.nearestCity]
|
||||
}
|
||||
|
||||
inner class BankFactory : MockNetwork.Factory {
|
||||
var counter = 0
|
||||
|
||||
override fun create(dir: Path, config: NodeConfiguration, network: MockNetwork, timestamperAddr: NodeInfo?): MockNetwork.MockNode {
|
||||
val letter = 'A' + counter
|
||||
val city = bankLocations[counter++ % bankLocations.size]
|
||||
val cfg = object : NodeConfiguration {
|
||||
// TODO: Set this back to "Bank of $city" after video day.
|
||||
override val myLegalName: String = "Bank $letter"
|
||||
override val exportJMXto: String = ""
|
||||
override val nearestCity: String = city
|
||||
}
|
||||
val node = SimulatedNode(dir, cfg, network, timestamperAddr)
|
||||
// TODO: This is obviously bogus: there should be a single network map for the whole simulated network.
|
||||
(node.services.networkMapCache as MockNetworkMapCache).ratesOracleNodes += ratesOracle.info
|
||||
(node.services.networkMapCache as MockNetworkMapCache).regulators += regulators.map { it.info }
|
||||
return node
|
||||
}
|
||||
|
||||
fun createAll(): List<SimulatedNode> = bankLocations.map { network.createNode(timestamper.info, nodeFactory = this) as SimulatedNode }
|
||||
}
|
||||
|
||||
val bankFactory = BankFactory()
|
||||
|
||||
object TimestampingNodeFactory : MockNetwork.Factory {
|
||||
override fun create(dir: Path, config: NodeConfiguration, network: MockNetwork, timestamperAddr: NodeInfo?): MockNetwork.MockNode {
|
||||
val cfg = object : NodeConfiguration {
|
||||
override val myLegalName: String = "Timestamping Service" // A magic string recognised by the CP contract
|
||||
override val exportJMXto: String = ""
|
||||
override val nearestCity: String = "Zurich"
|
||||
}
|
||||
return SimulatedNode(dir, cfg, network, timestamperAddr)
|
||||
}
|
||||
}
|
||||
|
||||
object RatesOracleFactory : MockNetwork.Factory {
|
||||
override fun create(dir: Path, config: NodeConfiguration, network: MockNetwork, timestamperAddr: NodeInfo?): MockNetwork.MockNode {
|
||||
val cfg = object : NodeConfiguration {
|
||||
override val myLegalName: String = "Rates Service Provider"
|
||||
override val exportJMXto: String = ""
|
||||
override val nearestCity: String = "Madrid"
|
||||
}
|
||||
|
||||
val n = object : SimulatedNode(dir, cfg, network, timestamperAddr) {
|
||||
override fun makeInterestRatesOracleService() {
|
||||
super.makeInterestRatesOracleService()
|
||||
interestRatesService.upload(javaClass.getResourceAsStream("example.rates.txt"))
|
||||
(services.networkMapCache as MockNetworkMapCache).ratesOracleNodes += info
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
}
|
||||
|
||||
object RegulatorFactory : MockNetwork.Factory {
|
||||
override fun create(dir: Path, config: NodeConfiguration, network: MockNetwork, timestamperAddr: NodeInfo?): MockNetwork.MockNode {
|
||||
val cfg = object : NodeConfiguration {
|
||||
override val myLegalName: String = "Regulator A"
|
||||
override val exportJMXto: String = ""
|
||||
override val nearestCity: String = "Paris"
|
||||
}
|
||||
|
||||
val n = object : SimulatedNode(dir, cfg, network, timestamperAddr) {
|
||||
// TODO: Regulatory nodes don't actually exist properly, this is a last minute demo request.
|
||||
// So we just fire a message at a node that doesn't know how to handle it, and it'll ignore it.
|
||||
// But that's fine for visualisation purposes.
|
||||
}
|
||||
return n
|
||||
}
|
||||
}
|
||||
|
||||
val network = MockNetwork(false)
|
||||
|
||||
val timestamper: SimulatedNode = network.createNode(null, nodeFactory = TimestampingNodeFactory) as SimulatedNode
|
||||
val ratesOracle: SimulatedNode = network.createNode(null, nodeFactory = RatesOracleFactory) as SimulatedNode
|
||||
val serviceProviders: List<SimulatedNode> = listOf(timestamper, ratesOracle)
|
||||
val banks: List<SimulatedNode> = bankFactory.createAll()
|
||||
val regulators: List<SimulatedNode> = listOf(network.createNode(null, nodeFactory = RegulatorFactory) as SimulatedNode)
|
||||
|
||||
private val _allProtocolSteps = PublishSubject.create<Pair<SimulatedNode, ProgressTracker.Change>>()
|
||||
private val _doneSteps = PublishSubject.create<Collection<SimulatedNode>>()
|
||||
val allProtocolSteps: Observable<Pair<SimulatedNode, ProgressTracker.Change>> = _allProtocolSteps
|
||||
val doneSteps: Observable<Collection<SimulatedNode>> = _doneSteps
|
||||
|
||||
private var pumpCursor = 0
|
||||
|
||||
/**
|
||||
* The current simulated date. By default this never changes. If you want it to change, you should do so from
|
||||
* within your overridden [iterate] call. Changes in the current day surface in the [dateChanges] observable.
|
||||
*/
|
||||
var currentDay: LocalDate = LocalDate.now()
|
||||
protected set(value) {
|
||||
field = value
|
||||
_dateChanges.onNext(value)
|
||||
}
|
||||
|
||||
private val _dateChanges = PublishSubject.create<LocalDate>()
|
||||
val dateChanges: Observable<LocalDate> = _dateChanges
|
||||
|
||||
/**
|
||||
* A place for simulations to stash human meaningful text about what the node is "thinking", which might appear
|
||||
* in the UI somewhere.
|
||||
*/
|
||||
val extraNodeLabels = Collections.synchronizedMap(HashMap<SimulatedNode, String>())
|
||||
|
||||
/**
|
||||
* Iterates the simulation by one step.
|
||||
*
|
||||
* The default implementation circles around the nodes, pumping until one of them handles a message. The next call
|
||||
* will carry on from where this one stopped. In an environment where you want to take actions between anything
|
||||
* interesting happening, or control the precise speed at which things operate (beyond the latency injector), this
|
||||
* is a useful way to do things.
|
||||
*/
|
||||
open fun iterate() {
|
||||
// Keep going until one of the nodes has something to do, or we have checked every node.
|
||||
val endpoints = network.messagingNetwork.endpoints
|
||||
var countDown = endpoints.size
|
||||
while (countDown > 0) {
|
||||
val handledMessage = endpoints[pumpCursor].pump(false)
|
||||
if (handledMessage) break
|
||||
// If this node had nothing to do, advance the cursor with wraparound and try again.
|
||||
pumpCursor = (pumpCursor + 1) % endpoints.size
|
||||
countDown--
|
||||
}
|
||||
}
|
||||
|
||||
protected fun linkProtocolProgress(node: SimulatedNode, protocol: ProtocolLogic<*>) {
|
||||
val pt = protocol.progressTracker ?: return
|
||||
pt.changes.subscribe { change: ProgressTracker.Change ->
|
||||
// Runs on node thread.
|
||||
_allProtocolSteps.onNext(Pair(node, change))
|
||||
}
|
||||
// This isn't technically a "change" but it helps with UIs to send this notification of the first step.
|
||||
_allProtocolSteps.onNext(Pair(node, ProgressTracker.Change.Position(pt, pt.steps[1])))
|
||||
}
|
||||
|
||||
protected fun linkConsensus(nodes: Collection<SimulatedNode>, protocol: ProtocolLogic<*>) {
|
||||
protocol.progressTracker?.changes?.subscribe { change: ProgressTracker.Change ->
|
||||
// Runs on node thread.
|
||||
if (protocol.progressTracker!!.currentStep == ProgressTracker.DONE) {
|
||||
_doneSteps.onNext(nodes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
open fun start() {}
|
||||
|
||||
fun stop() {
|
||||
network.nodes.forEach { it.stop() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a function that returns a future, iterates that function with arguments like (0, 1), (1, 2), (2, 3) etc
|
||||
* each time the returned future completes.
|
||||
*/
|
||||
fun startTradingCircle(tradeBetween: (indexA: Int, indexB: Int) -> ListenableFuture<*>) {
|
||||
fun next(i: Int, j: Int) {
|
||||
tradeBetween(i, j).then {
|
||||
val ni = (i + 1) % banks.size
|
||||
val nj = (j + 1) % banks.size
|
||||
next(ni, nj)
|
||||
}
|
||||
}
|
||||
next(0, 1)
|
||||
}
|
||||
}
|
60
src/main/kotlin/core/testing/TradeSimulation.kt
Normal file
60
src/main/kotlin/core/testing/TradeSimulation.kt
Normal file
@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Copyright 2015 Distributed Ledger Group LLC. Distributed as Licensed Company IP to DLG Group Members
|
||||
* pursuant to the August 7, 2015 Advisory Services Agreement and subject to the Company IP License terms
|
||||
* set forth therein.
|
||||
*
|
||||
* All other rights reserved.
|
||||
*/
|
||||
|
||||
package core.testing
|
||||
|
||||
import com.google.common.util.concurrent.Futures
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import contracts.CommercialPaper
|
||||
import core.*
|
||||
import core.node.services.NodeWalletService
|
||||
import core.utilities.BriefLogFormatter
|
||||
import protocols.TwoPartyTradeProtocol
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* Simulates a never ending series of trades that go pair-wise through the banks (e.g. A and B trade with each other,
|
||||
* then B and C trade with each other, then C and A etc).
|
||||
*/
|
||||
class TradeSimulation(runAsync: Boolean, latencyInjector: InMemoryMessagingNetwork.LatencyCalculator?) : Simulation(runAsync, latencyInjector) {
|
||||
override fun start() {
|
||||
BriefLogFormatter.loggingOn("bank", "core.TransactionGroup", "recordingmap")
|
||||
startTradingCircle { i, j -> tradeBetween(i, j) }
|
||||
}
|
||||
|
||||
private fun tradeBetween(buyerBankIndex: Int, sellerBankIndex: Int): ListenableFuture<MutableList<SignedTransaction>> {
|
||||
val buyer = banks[buyerBankIndex]
|
||||
val seller = banks[sellerBankIndex]
|
||||
|
||||
(buyer.services.walletService as NodeWalletService).fillWithSomeTestCash(1500.DOLLARS)
|
||||
|
||||
val issuance = run {
|
||||
val tx = CommercialPaper().generateIssue(seller.info.identity.ref(1, 2, 3), 1100.DOLLARS, Instant.now() + 10.days)
|
||||
tx.setTime(Instant.now(), timestamper.info.identity, 30.seconds)
|
||||
tx.signWith(timestamper.storage.myLegalIdentityKey)
|
||||
tx.signWith(seller.storage.myLegalIdentityKey)
|
||||
tx.toSignedTransaction(true)
|
||||
}
|
||||
seller.services.storageService.validatedTransactions[issuance.id] = issuance
|
||||
|
||||
val sessionID = random63BitValue()
|
||||
val buyerProtocol = TwoPartyTradeProtocol.Buyer(seller.net.myAddress, timestamper.info.identity,
|
||||
1000.DOLLARS, CommercialPaper.State::class.java, sessionID)
|
||||
val sellerProtocol = TwoPartyTradeProtocol.Seller(buyer.net.myAddress, timestamper.info,
|
||||
issuance.tx.outRef(0), 1000.DOLLARS, seller.storage.myLegalIdentityKey, sessionID)
|
||||
|
||||
linkConsensus(listOf(buyer, seller, timestamper), sellerProtocol)
|
||||
linkProtocolProgress(buyer, buyerProtocol)
|
||||
linkProtocolProgress(seller, sellerProtocol)
|
||||
|
||||
val buyerFuture = buyer.smm.add("bank.$buyerBankIndex.${TwoPartyTradeProtocol.TRADE_TOPIC}.buyer", buyerProtocol)
|
||||
val sellerFuture = seller.smm.add("bank.$sellerBankIndex.${TwoPartyTradeProtocol.TRADE_TOPIC}.seller", sellerProtocol)
|
||||
|
||||
return Futures.successfulAsList(buyerFuture, sellerFuture)
|
||||
}
|
||||
}
|
137
src/main/kotlin/core/utilities/JsonSupport.kt
Normal file
137
src/main/kotlin/core/utilities/JsonSupport.kt
Normal file
@ -0,0 +1,137 @@
|
||||
/*
|
||||
* Copyright 2015 Distributed Ledger Group LLC. Distributed as Licensed Company IP to DLG Group Members
|
||||
* pursuant to the August 7, 2015 Advisory Services Agreement and subject to the Company IP License terms
|
||||
* set forth therein.
|
||||
*
|
||||
* All other rights reserved.
|
||||
*/
|
||||
|
||||
package core.utilities
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator
|
||||
import com.fasterxml.jackson.core.JsonParseException
|
||||
import com.fasterxml.jackson.core.JsonParser
|
||||
import com.fasterxml.jackson.core.JsonToken
|
||||
import com.fasterxml.jackson.databind.*
|
||||
import com.fasterxml.jackson.databind.deser.std.NumberDeserializers
|
||||
import com.fasterxml.jackson.databind.deser.std.StringArrayDeserializer
|
||||
import com.fasterxml.jackson.databind.module.SimpleModule
|
||||
import com.fasterxml.jackson.module.kotlin.KotlinModule
|
||||
import core.BusinessCalendar
|
||||
import core.Party
|
||||
import core.crypto.SecureHash
|
||||
import core.node.services.IdentityService
|
||||
import java.math.BigDecimal
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
|
||||
/**
|
||||
* Utilities and serialisers for working with JSON representations of basic types. This adds Jackson support for
|
||||
* the java.time API, some core types, and Kotlin data classes.
|
||||
*/
|
||||
object JsonSupport {
|
||||
fun createDefaultMapper(identities: IdentityService): ObjectMapper {
|
||||
val mapper = ServiceHubObjectMapper(identities)
|
||||
mapper.enable(SerializationFeature.INDENT_OUTPUT);
|
||||
mapper.enable(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY)
|
||||
mapper.enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS)
|
||||
|
||||
val timeModule = SimpleModule("java.time")
|
||||
timeModule.addSerializer(LocalDate::class.java, ToStringSerializer)
|
||||
timeModule.addDeserializer(LocalDate::class.java, LocalDateDeserializer)
|
||||
timeModule.addKeyDeserializer(LocalDate::class.java, LocalDateKeyDeserializer)
|
||||
timeModule.addSerializer(LocalDateTime::class.java, ToStringSerializer)
|
||||
|
||||
val cordaModule = SimpleModule("core")
|
||||
cordaModule.addSerializer(Party::class.java, PartySerializer)
|
||||
cordaModule.addDeserializer(Party::class.java, PartyDeserializer)
|
||||
cordaModule.addSerializer(BigDecimal::class.java, ToStringSerializer)
|
||||
cordaModule.addDeserializer(BigDecimal::class.java, NumberDeserializers.BigDecimalDeserializer())
|
||||
cordaModule.addSerializer(SecureHash::class.java, SecureHashSerializer)
|
||||
// It's slightly remarkable, but apparently Jackson works out that this is the only possibility
|
||||
// for a SecureHash at the moment and tries to use SHA256 directly even though we only give it SecureHash
|
||||
cordaModule.addDeserializer(SecureHash.SHA256::class.java, SecureHashDeserializer())
|
||||
cordaModule.addDeserializer(BusinessCalendar::class.java, CalendarDeserializer)
|
||||
|
||||
mapper.registerModule(timeModule)
|
||||
mapper.registerModule(cordaModule)
|
||||
mapper.registerModule(KotlinModule())
|
||||
return mapper
|
||||
}
|
||||
|
||||
class ServiceHubObjectMapper(val identities: IdentityService): ObjectMapper()
|
||||
|
||||
object ToStringSerializer: JsonSerializer<Any>() {
|
||||
override fun serialize(obj: Any, generator: JsonGenerator, provider: SerializerProvider) {
|
||||
generator.writeString(obj.toString())
|
||||
}
|
||||
}
|
||||
|
||||
object LocalDateDeserializer: JsonDeserializer<LocalDate>() {
|
||||
override fun deserialize(parser: JsonParser, context: DeserializationContext): LocalDate {
|
||||
return try {
|
||||
LocalDate.parse(parser.text)
|
||||
} catch (e: Exception) {
|
||||
throw JsonParseException("Invalid LocalDate ${parser.text}: ${e.message}", parser.currentLocation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object LocalDateKeyDeserializer: KeyDeserializer() {
|
||||
override fun deserializeKey(text: String, p1: DeserializationContext): Any? {
|
||||
return LocalDate.parse(text)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
object PartySerializer: JsonSerializer<Party>() {
|
||||
override fun serialize(obj: Party, generator: JsonGenerator, provider: SerializerProvider) {
|
||||
generator.writeString(obj.name)
|
||||
}
|
||||
}
|
||||
|
||||
object PartyDeserializer: JsonDeserializer<Party>() {
|
||||
override fun deserialize(parser: JsonParser, context: DeserializationContext): Party {
|
||||
if(parser.currentToken == JsonToken.FIELD_NAME) {
|
||||
parser.nextToken()
|
||||
}
|
||||
val mapper = parser.codec as ServiceHubObjectMapper
|
||||
// TODO this needs to use some industry identifier(s) not just these human readable names
|
||||
return mapper.identities.partyFromName(parser.text) ?: throw JsonParseException("Could not find a Party with name: ${parser.text}", parser.currentLocation)
|
||||
}
|
||||
}
|
||||
|
||||
object SecureHashSerializer: JsonSerializer<SecureHash>() {
|
||||
override fun serialize(obj: SecureHash, generator: JsonGenerator, provider: SerializerProvider) {
|
||||
generator.writeString(obj.toString())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Implemented as a class so that we can instantiate for T
|
||||
*/
|
||||
class SecureHashDeserializer<T : SecureHash>: JsonDeserializer<T>() {
|
||||
override fun deserialize(parser: JsonParser, context: DeserializationContext): T {
|
||||
if(parser.currentToken == JsonToken.FIELD_NAME) {
|
||||
parser.nextToken()
|
||||
}
|
||||
try {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return SecureHash.parse(parser.text) as T
|
||||
} catch (e: Exception) {
|
||||
throw JsonParseException("Invalid hash ${parser.text}: ${e.message}", parser.currentLocation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object CalendarDeserializer: JsonDeserializer<BusinessCalendar>() {
|
||||
override fun deserialize(parser: JsonParser, context: DeserializationContext): BusinessCalendar {
|
||||
return try {
|
||||
val array = StringArrayDeserializer.instance.deserialize(parser, context)
|
||||
BusinessCalendar.getInstance(*array)
|
||||
} catch (e: Exception) {
|
||||
throw JsonParseException("Invalid calendar(s) ${parser.text}: ${e.message}", parser.currentLocation)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -73,8 +73,11 @@ object TwoPartyDealProtocol {
|
||||
object TIMESTAMPING : ProgressTracker.Step("Timestamping transaction")
|
||||
object SENDING_SIGS : ProgressTracker.Step("Sending transaction signatures to other party")
|
||||
object RECORDING : ProgressTracker.Step("Recording completed transaction")
|
||||
object COPYING_TO_REGULATOR : ProgressTracker.Step("Copying regulator")
|
||||
|
||||
fun tracker() = ProgressTracker(AWAITING_PROPOSAL, VERIFYING, SIGNING, TIMESTAMPING, SENDING_SIGS, RECORDING)
|
||||
fun tracker() = ProgressTracker(AWAITING_PROPOSAL, VERIFYING, SIGNING, TIMESTAMPING, SENDING_SIGS, RECORDING, COPYING_TO_REGULATOR).apply {
|
||||
childrenFor[TIMESTAMPING] = TimestampingProtocol.tracker()
|
||||
}
|
||||
}
|
||||
|
||||
@Suspendable
|
||||
@ -149,6 +152,15 @@ object TwoPartyDealProtocol {
|
||||
|
||||
logger.trace { "Deal stored" }
|
||||
|
||||
val regulators = serviceHub.networkMapCache.regulators
|
||||
if (regulators.isNotEmpty()) {
|
||||
// Copy the transaction to every regulator in the network. This is obviously completely bogus, it's
|
||||
// just for demo purposes.
|
||||
for (regulator in regulators) {
|
||||
send("regulator.all.seeing.eye", regulator.address, 0, fullySigned)
|
||||
}
|
||||
}
|
||||
|
||||
return fullySigned
|
||||
}
|
||||
|
||||
|
51
src/main/resources/core/testing/example.rates.txt
Normal file
51
src/main/resources/core/testing/example.rates.txt
Normal file
@ -0,0 +1,51 @@
|
||||
# Some pretend noddy rate fixes, for the interest rate oracles.
|
||||
|
||||
3M USD 2016-03-16 1M = 0.678
|
||||
3M USD 2016-03-16 2M = 0.655
|
||||
EURIBOR 2016-03-15 1M = 0.123
|
||||
EURIBOR 2016-03-15 2M = 0.111
|
||||
|
||||
3M USD 2016-03-08 3M = 0.0063515
|
||||
3M USD 2016-06-08 3M = 0.0063520
|
||||
3M USD 2016-09-08 3M = 0.0063521
|
||||
3M USD 2016-12-08 3M = 0.0063515
|
||||
3M USD 2017-03-08 3M = 0.0063525
|
||||
3M USD 2017-06-08 3M = 0.0063530
|
||||
3M USD 2017-09-07 3M = 0.0063531
|
||||
3M USD 2017-12-07 3M = 0.0063532
|
||||
3M USD 2018-03-08 3M = 0.0063533
|
||||
3M USD 2018-06-07 3M = 0.0063534
|
||||
3M USD 2018-09-06 3M = 0.0063535
|
||||
3M USD 2018-12-06 3M = 0.0063536
|
||||
3M USD 2019-03-07 3M = 0.0063537
|
||||
3M USD 2019-06-06 3M = 0.0063538
|
||||
3M USD 2019-09-06 3M = 0.0063539
|
||||
3M USD 2019-12-06 3M = 0.0063540
|
||||
3M USD 2020-03-06 3M = 0.0063541
|
||||
3M USD 2020-06-08 3M = 0.0063542
|
||||
3M USD 2020-09-08 3M = 0.0063543
|
||||
3M USD 2020-12-08 3M = 0.0063544
|
||||
3M USD 2021-03-08 3M = 0.0063545
|
||||
3M USD 2021-06-08 3M = 0.0063546
|
||||
3M USD 2021-09-08 3M = 0.0063547
|
||||
3M USD 2021-12-08 3M = 0.0063548
|
||||
3M USD 2022-03-08 3M = 0.0063549
|
||||
3M USD 2022-06-08 3M = 0.0063550
|
||||
3M USD 2022-09-08 3M = 0.0063551
|
||||
3M USD 2022-12-08 3M = 0.0063553
|
||||
3M USD 2023-03-08 3M = 0.0063554
|
||||
3M USD 2023-06-08 3M = 0.0063555
|
||||
3M USD 2023-09-07 3M = 0.0063556
|
||||
3M USD 2023-12-07 3M = 0.0063557
|
||||
3M USD 2024-03-07 3M = 0.0063558
|
||||
3M USD 2024-06-06 3M = 0.0063559
|
||||
3M USD 2024-09-06 3M = 0.0063560
|
||||
3M USD 2024-12-06 3M = 0.0063561
|
||||
3M USD 2025-03-06 3M = 0.0063562
|
||||
3M USD 2025-06-06 3M = 0.0063563
|
||||
3M USD 2025-09-08 3M = 0.0063564
|
||||
3M USD 2025-12-08 3M = 0.0063565
|
||||
3M USD 2026-03-06 3M = 0.0063566
|
||||
3M USD 2026-06-08 3M = 0.0063567
|
||||
3M USD 2026-09-08 3M = 0.0063568
|
||||
3M USD 2026-12-08 3M = 0.0063569
|
104
src/main/resources/core/testing/trade.json
Normal file
104
src/main/resources/core/testing/trade.json
Normal file
@ -0,0 +1,104 @@
|
||||
{
|
||||
"fixedLeg": {
|
||||
"fixedRatePayer": "Bank A",
|
||||
"notional": {
|
||||
"pennies": 2500000000,
|
||||
"currency": "USD"
|
||||
},
|
||||
"paymentFrequency": "SemiAnnual",
|
||||
"effectiveDate": "2016-03-16",
|
||||
"effectiveDateAdjustment": null,
|
||||
"terminationDate": "2026-03-16",
|
||||
"terminationDateAdjustment": null,
|
||||
"fixedRate": {
|
||||
"ratioUnit": {
|
||||
"value": "0.01676"
|
||||
}
|
||||
},
|
||||
"dayCountBasisDay": "D30",
|
||||
"dayCountBasisYear": "Y360",
|
||||
"rollConvention": "ModifiedFollowing",
|
||||
"dayInMonth": 10,
|
||||
"paymentRule": "InArrears",
|
||||
"paymentDelay": 0,
|
||||
"paymentCalendar": "London",
|
||||
"interestPeriodAdjustment": "Adjusted"
|
||||
},
|
||||
"floatingLeg": {
|
||||
"floatingRatePayer": "Bank B",
|
||||
"notional": {
|
||||
"pennies": 2500000000,
|
||||
"currency": "USD"
|
||||
},
|
||||
"paymentFrequency": "Quarterly",
|
||||
"effectiveDate": "2016-03-12",
|
||||
"effectiveDateAdjustment": null,
|
||||
"terminationDate": "2026-03-12",
|
||||
"terminationDateAdjustment": null,
|
||||
"dayCountBasisDay": "D30",
|
||||
"dayCountBasisYear": "Y360",
|
||||
"rollConvention": "ModifiedFollowing",
|
||||
"fixingRollConvention": "ModifiedFollowing",
|
||||
"dayInMonth": 10,
|
||||
"resetDayInMonth": 10,
|
||||
"paymentRule": "InArrears",
|
||||
"paymentDelay": 0,
|
||||
"paymentCalendar": [ "London" ],
|
||||
"interestPeriodAdjustment": "Adjusted",
|
||||
"fixingPeriod": "TWODAYS",
|
||||
"resetRule": "InAdvance",
|
||||
"fixingsPerPayment": "Quarterly",
|
||||
"fixingCalendar": [ "NewYork" ],
|
||||
"index": "3M USD",
|
||||
"indexSource": "Rates Service Provider",
|
||||
"indexTenor": {
|
||||
"name": "3M"
|
||||
}
|
||||
},
|
||||
"calculation": {
|
||||
"expression": "( fixedLeg.notional.pennies * (fixedLeg.fixedRate.ratioUnit.value)) -(floatingLeg.notional.pennies * (calculation.fixingSchedule.get(context.getDate('currentDate')).rate.ratioUnit.value))",
|
||||
"floatingLegPaymentSchedule": {
|
||||
},
|
||||
"fixedLegPaymentSchedule": {
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"baseCurrency": "EUR",
|
||||
"eligibleCurrency": "EUR",
|
||||
"eligibleCreditSupport": "Cash in an Eligible Currency",
|
||||
"independentAmounts": {
|
||||
"pennies": 0,
|
||||
"currency": "EUR"
|
||||
},
|
||||
"threshold": {
|
||||
"pennies": 0,
|
||||
"currency": "EUR"
|
||||
},
|
||||
"minimumTransferAmount": {
|
||||
"pennies": 25000000,
|
||||
"currency": "EUR"
|
||||
},
|
||||
"rounding": {
|
||||
"pennies": 1000000,
|
||||
"currency": "EUR"
|
||||
},
|
||||
"valuationDate": "Every Local Business Day",
|
||||
"notificationTime": "2:00pm London",
|
||||
"resolutionTime": "2:00pm London time on the first LocalBusiness Day following the date on which the notice is given ",
|
||||
"interestRate": {
|
||||
"oracle": "Rates Service Provider",
|
||||
"tenor": {
|
||||
"name": "6M"
|
||||
},
|
||||
"ratioUnit": null,
|
||||
"name": "EONIA"
|
||||
},
|
||||
"addressForTransfers": "",
|
||||
"exposure": {},
|
||||
"localBusinessDay": [ "London" , "NewYork" ],
|
||||
"dailyInterestAmount": "(CashAmount * InterestRate ) / (fixedLeg.notional.currency.currencyCode.equals('GBP')) ? 365 : 360",
|
||||
"tradeID": "tradeXXX",
|
||||
"hashLegalDocs": "put hash here"
|
||||
},
|
||||
"programRef": "1E6BBA305D445341F0026E51B6C7F3ACB834AFC6C2510C0EF7BC0477235EFECF"
|
||||
}
|
@ -14,7 +14,7 @@ import core.messaging.MessagingService
|
||||
import core.node.services.*
|
||||
import core.serialization.SerializedBytes
|
||||
import core.serialization.deserialize
|
||||
import core.testutils.TEST_KEYS_TO_CORP_MAP
|
||||
import core.testutils.MockIdentityService
|
||||
import core.testutils.TEST_PROGRAM_MAP
|
||||
import core.testutils.TEST_TX_TIME
|
||||
import java.io.ByteArrayInputStream
|
||||
@ -50,10 +50,6 @@ class DummyTimestamper(var clock: Clock = Clock.fixed(TEST_TX_TIME, ZoneId.syste
|
||||
|
||||
val DUMMY_TIMESTAMPER = DummyTimestamper()
|
||||
|
||||
object MockIdentityService : IdentityService {
|
||||
override fun partyFromKey(key: PublicKey): Party? = TEST_KEYS_TO_CORP_MAP[key]
|
||||
}
|
||||
|
||||
class MockKeyManagementService(vararg initialKeys: KeyPair) : KeyManagementService {
|
||||
override val keys: MutableMap<PublicKey, PrivateKey>
|
||||
|
||||
|
@ -48,7 +48,7 @@ class TwoPartyTradeProtocolTests : TestWithInMemoryNetwork() {
|
||||
@Before
|
||||
fun before() {
|
||||
net = MockNetwork(false)
|
||||
net.identities += TEST_KEYS_TO_CORP_MAP.values
|
||||
net.identities += MockIdentityService.identities
|
||||
BriefLogFormatter.loggingOn("platform.trade", "core.TransactionGroup", "recordingmap")
|
||||
}
|
||||
|
||||
|
@ -11,10 +11,7 @@ package core.serialization
|
||||
import contracts.Cash
|
||||
import core.*
|
||||
import core.crypto.SecureHash
|
||||
import core.testutils.DUMMY_PUBKEY_1
|
||||
import core.testutils.MINI_CORP
|
||||
import core.testutils.TEST_TX_TIME
|
||||
import core.testutils.TestUtils
|
||||
import core.testutils.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.security.SignatureException
|
||||
|
@ -15,6 +15,7 @@ import contracts.*
|
||||
import core.*
|
||||
import core.crypto.*
|
||||
import core.node.services.DummyTimestampingAuthority
|
||||
import core.node.services.FixedIdentityService
|
||||
import core.serialization.serialize
|
||||
import core.visualiser.GraphVisualiser
|
||||
import java.security.KeyPair
|
||||
@ -66,11 +67,7 @@ val MINI_CORP = Party("MiniCorp", MINI_CORP_PUBKEY)
|
||||
|
||||
val ALL_TEST_KEYS = listOf(MEGA_CORP_KEY, MINI_CORP_KEY, ALICE_KEY, BOB_KEY, DummyTimestampingAuthority.key)
|
||||
|
||||
val TEST_KEYS_TO_CORP_MAP: Map<PublicKey, Party> = mapOf(
|
||||
MEGA_CORP_PUBKEY to MEGA_CORP,
|
||||
MINI_CORP_PUBKEY to MINI_CORP,
|
||||
DUMMY_TIMESTAMPER.identity.owningKey to DUMMY_TIMESTAMPER.identity
|
||||
)
|
||||
val MockIdentityService = FixedIdentityService(listOf(MEGA_CORP, MINI_CORP, DUMMY_TIMESTAMPER.identity))
|
||||
|
||||
// In a real system this would be a persistent map of hash to bytecode and we'd instantiate the object as needed inside
|
||||
// a sandbox. For unit tests we just have a hard-coded list.
|
||||
@ -128,7 +125,7 @@ abstract class AbstractTransactionForTest {
|
||||
open fun output(label: String? = null, s: () -> ContractState) = LabeledOutput(label, s()).apply { outStates.add(this) }
|
||||
|
||||
protected fun commandsToAuthenticatedObjects(): List<AuthenticatedObject<CommandData>> {
|
||||
return commands.map { AuthenticatedObject(it.pubkeys, it.pubkeys.mapNotNull { TEST_KEYS_TO_CORP_MAP[it] }, it.data) }
|
||||
return commands.map { AuthenticatedObject(it.pubkeys, it.pubkeys.mapNotNull { MockIdentityService.partyFromKey(it) }, it.data) }
|
||||
}
|
||||
|
||||
fun attachment(attachmentID: SecureHash) {
|
||||
|
Loading…
x
Reference in New Issue
Block a user