Add basic DLC Oracle (#2094)

* Add basic DLC Oracle

* Respond to review

* Respond to more review

* Add maturation time

* Add to testkit, tag hashes, better val names

* More clear vals, version tagged hashes

* Signing key clean up

* Add pubkey to db
This commit is contained in:
Ben Carman 2020-10-03 08:04:57 -05:00 committed by GitHub
parent e71b664e1a
commit d57c059921
12 changed files with 477 additions and 36 deletions

View file

@ -2,12 +2,24 @@ package org.bitcoins.commons.jsonmodels.dlc
import org.bitcoins.crypto.StringFactory
sealed abstract class SigningVersion
sealed abstract class SigningVersion {
def nonceTag: String
def commitmentTag: String
def outcomeTag: String
}
object SigningVersion extends StringFactory[SigningVersion] {
/** Initial signing version that was created with krystal bull, not a part of any spec */
final case object Mock extends SigningVersion
final case object Mock extends SigningVersion {
override def nonceTag: String = "DLCv0/Nonce"
override def commitmentTag: String = "DLCv0/Commitment"
override def outcomeTag: String = "DLCv0/Outcome"
}
val latest: SigningVersion = Mock
val all: Vector[SigningVersion] = Vector(Mock)

View file

@ -436,7 +436,8 @@ lazy val testkit = project
eclairRpc,
node,
wallet,
zmq
zmq,
dlcOracle
)
lazy val docs = project

View file

@ -0,0 +1,118 @@
package org.bitcoins.dlc.oracle
import java.sql.SQLException
import org.bitcoins.core.util.TimeUtil
import org.bitcoins.crypto._
import org.bitcoins.testkit.fixtures.DLCOracleFixture
class DLCOracleTest extends DLCOracleFixture {
val testOutcomes: Vector[String] = (0 to 10).map(_.toString).toVector
behavior of "DLCOracle"
it must "correctly initialize" in { dlcOracle: DLCOracle =>
assert(dlcOracle.conf.seedExists())
}
it must "start with no events" in { dlcOracle: DLCOracle =>
dlcOracle.listEventDbs().map { events =>
assert(events.isEmpty)
}
}
it must "start with no pending events" in { dlcOracle: DLCOracle =>
dlcOracle.listPendingEventDbs().map { events =>
assert(events.isEmpty)
}
}
it must "create a new event and list it with pending" in {
dlcOracle: DLCOracle =>
val time = TimeUtil.now
for {
testEventDb <- dlcOracle.createNewEvent("test", time, testOutcomes)
pendingEvents <- dlcOracle.listPendingEventDbs()
} yield {
assert(pendingEvents.size == 1)
// encoding of the time can make them unequal
val comparable =
pendingEvents.head.copy(maturationTime = testEventDb.maturationTime)
assert(comparable == testEventDb)
}
}
it must "create a new event with a valid commitment signature" in {
dlcOracle: DLCOracle =>
for {
testEventDb <-
dlcOracle.createNewEvent("test", TimeUtil.now, testOutcomes)
rValDbOpt <- dlcOracle.rValueDAO.read(testEventDb.nonce)
} yield {
assert(rValDbOpt.isDefined)
val rValDb = rValDbOpt.get
val hash = CryptoUtil.taggedSha256(
rValDb.nonce.bytes ++ CryptoUtil.serializeForHash(rValDb.eventName),
"DLCv0/Commitment")
assert(
dlcOracle.publicKey.verify(hash.bytes, rValDb.commitmentSignature))
}
}
it must "create multiple events with different names" in {
dlcOracle: DLCOracle =>
for {
_ <- dlcOracle.createNewEvent("test", TimeUtil.now, testOutcomes)
_ <- dlcOracle.createNewEvent("test1", TimeUtil.now, testOutcomes)
} yield succeed
}
it must "fail to create multiple events with the same name" in {
dlcOracle: DLCOracle =>
recoverToSucceededIf[SQLException] {
for {
_ <- dlcOracle.createNewEvent("test", TimeUtil.now, testOutcomes)
_ <- dlcOracle.createNewEvent("test", TimeUtil.now, testOutcomes)
} yield ()
}
}
it must "create and sign a event" in { dlcOracle: DLCOracle =>
val outcome = testOutcomes.head
for {
eventDb <- dlcOracle.createNewEvent("test", TimeUtil.now, testOutcomes)
signedEventDb <- dlcOracle.signEvent(eventDb.nonce, outcome)
outcomeDbs <- dlcOracle.eventOutcomeDAO.findByNonce(eventDb.nonce)
outcomeDb = outcomeDbs.find(_.message == outcome).get
signedEvent <- dlcOracle.eventDAO.read(eventDb.nonce)
} yield {
val sig = signedEventDb.sigOpt.get
assert(signedEvent.isDefined)
assert(signedEvent.get.attestationOpt.contains(sig.sig))
assert(dlcOracle.publicKey.verify(outcomeDb.hashedMessage.bytes, sig))
assert(
SchnorrDigitalSignature(signedEvent.get.nonce,
signedEvent.get.attestationOpt.get) == sig)
}
}
it must "fail to sign an event that doesn't exist" in {
dlcOracle: DLCOracle =>
val dummyNonce = SchnorrNonce(ECPublicKey.freshPublicKey.bytes.tail)
recoverToSucceededIf[RuntimeException](
dlcOracle.signEvent(dummyNonce, "testOutcomes"))
}
it must "fail to sign an outcome that doesn't exist" in {
dlcOracle: DLCOracle =>
recoverToSucceededIf[RuntimeException] {
for {
eventDb <-
dlcOracle.createNewEvent("test", TimeUtil.now, testOutcomes)
_ <- dlcOracle.signEvent(eventDb.nonce, "not a real outcome")
} yield ()
}
}
}

View file

@ -1,26 +1,28 @@
CREATE TABLE r_values
(
nonce TEXT NOT NULL,
label TEXT NOT NULL UNIQUE,
event_name TEXT NOT NULL UNIQUE,
hd_purpose INTEGER NOT NULL,
coin INTEGER NOT NULL,
account_index INTEGER NOT NULL,
chain_type INTEGER NOT NULL,
key_index INTEGER NOT NULL,
key_index INTEGER NOT NULL UNIQUE,
commitment_signature TEXT NOT NULL,
PRIMARY KEY (nonce)
);
CREATE TABLE events
(
nonce TEXT NOT NULL,
label TEXT NOT NULL UNIQUE,
num_outcomes INTEGER NOT NULL,
signing_version TEXT NOT NULL,
nonce TEXT NOT NULL,
pubkey TEXT NOT NULL,
event_name TEXT NOT NULL UNIQUE,
num_outcomes INTEGER NOT NULL,
signing_version TEXT NOT NULL,
maturation_time TIMESTAMP NOT NULL,
attestation TEXT,
CONSTRAINT fk_label FOREIGN KEY (label) REFERENCES r_values (label) on update NO ACTION on delete NO ACTION,
PRIMARY KEY (nonce),
CONSTRAINT fk_nonce FOREIGN KEY (nonce) REFERENCES r_values (nonce) on update NO ACTION on delete NO ACTION
CONSTRAINT fk_nonce FOREIGN KEY (nonce) REFERENCES r_values (nonce) on update NO ACTION on delete NO ACTION,
CONSTRAINT fk_label FOREIGN KEY (event_name) REFERENCES r_values (event_name) on update NO ACTION on delete NO ACTION
);
CREATE TABLE event_outcomes

View file

@ -1,12 +1,12 @@
CREATE TABLE `r_values`
(
`nonce` VARCHAR(254) NOT NULL,
`label` VARCHAR(254) NOT NULL UNIQUE,
`event_name` VARCHAR(254) NOT NULL UNIQUE,
`hd_purpose` INTEGER NOT NULL,
`coin` INTEGER NOT NULL,
`account_index` INTEGER NOT NULL,
`chain_type` INTEGER NOT NULL,
`key_index` INTEGER NOT NULL,
`key_index` INTEGER NOT NULL UNIQUE,
`commitment_signature` VARCHAR(254) NOT NULL,
PRIMARY KEY (`nonce`)
);
@ -14,13 +14,15 @@ CREATE TABLE `r_values`
CREATE TABLE `events`
(
`nonce` VARCHAR(254) NOT NULL,
`label` VARCHAR(254) NOT NULL UNIQUE,
`pubkey` VARCHAR(254) NOT NULL,
`event_name` VARCHAR(254) NOT NULL UNIQUE,
`num_outcomes` INTEGER NOT NULL,
`signing_version` VARCHAR(254) NOT NULL,
`maturation_time` TIMESTAMP NOT NULL,
`attestation` VARCHAR(254),
CONSTRAINT `fk_label` FOREIGN KEY (`label`) REFERENCES `r_values` (`label`) on update NO ACTION on delete NO ACTION,
PRIMARY KEY (`nonce`),
CONSTRAINT `fk_nonce` FOREIGN KEY (`nonce`) REFERENCES `r_values` (`nonce`) on update NO ACTION on delete NO ACTION
CONSTRAINT `fk_nonce` FOREIGN KEY (`nonce`) REFERENCES `r_values` (`nonce`) on update NO ACTION on delete NO ACTION,
CONSTRAINT `fk_label` FOREIGN KEY (`event_name`) REFERENCES `r_values` (`event_name`) on update NO ACTION on delete NO ACTION
);
CREATE TABLE `event_outcomes`

View file

@ -0,0 +1,234 @@
package org.bitcoins.dlc.oracle
import java.time.Instant
import org.bitcoins.commons.jsonmodels.dlc.SigningVersion
import org.bitcoins.commons.jsonmodels.dlc.SigningVersion._
import org.bitcoins.core.config.BitcoinNetwork
import org.bitcoins.core.crypto.ExtKeyVersion.SegWitMainNetPriv
import org.bitcoins.core.crypto.{ExtPrivateKeyHardened, MnemonicCode}
import org.bitcoins.core.hd._
import org.bitcoins.core.protocol.Bech32Address
import org.bitcoins.core.protocol.script.P2WPKHWitnessSPKV0
import org.bitcoins.core.util.TimeUtil
import org.bitcoins.crypto._
import org.bitcoins.dlc.oracle.storage._
import org.bitcoins.keymanager.{DecryptedMnemonic, WalletStorage}
import scala.concurrent.{ExecutionContext, Future}
case class DLCOracle(private val extPrivateKey: ExtPrivateKeyHardened)(implicit
val conf: DLCOracleAppConfig) {
implicit val ec: ExecutionContext = conf.ec
// 585 is a random one I picked, unclaimed in https://github.com/satoshilabs/slips/blob/master/slip-0044.md
private val R_VALUE_PURPOSE = 585
private val rValAccount: HDAccount = {
val coin =
HDCoin(HDPurpose(R_VALUE_PURPOSE), HDCoinType.fromNetwork(conf.network))
HDAccount(coin, 0)
}
/** The chain index used in the bip32 paths for generating R values */
private val rValueChainIndex = 0
private def signingKey: ECPrivateKey = {
val coin = HDCoin(HDPurposes.SegWit, HDCoinType.fromNetwork(conf.network))
val account = HDAccount(coin, 0)
val purpose = coin.purpose
val chain = HDChainType.External
val index = 0
val path = BIP32Path.fromString(
s"m/${purpose.constant}'/${coin.coinType.toInt}'/${account.index}'/${chain.index}'/$index'")
extPrivateKey.deriveChildPrivKey(path).key
}
val publicKey: SchnorrPublicKey = signingKey.schnorrPublicKey
def stakingAddress(network: BitcoinNetwork): Bech32Address =
Bech32Address(P2WPKHWitnessSPKV0(signingKey.publicKey), network)
protected[bitcoins] val rValueDAO: RValueDAO = RValueDAO()
protected[bitcoins] val eventDAO: EventDAO = EventDAO()
protected[bitcoins] val eventOutcomeDAO: EventOutcomeDAO = EventOutcomeDAO()
private def getPath(keyIndex: Int): BIP32Path = {
val accountIndex = rValAccount.index
val coin = rValAccount.coin
val purpose = coin.purpose
BIP32Path.fromString(
s"m/${purpose.constant}'/${coin.coinType.toInt}'/$accountIndex'/$rValueChainIndex'/$keyIndex'")
}
private def getKValue(
rValDb: RValueDb,
signingVersion: SigningVersion): ECPrivateKey =
getKValue(rValDb.eventName, rValDb.path, signingVersion)
private def getKValue(
label: String,
path: BIP32Path,
signingVersion: SigningVersion): ECPrivateKey = {
require(path.forall(_.hardened),
s"Cannot use a BIP32Path with unhardened nodes, got $path")
val priv = extPrivateKey.deriveChildPrivKey(path).key
val hash =
CryptoUtil.taggedSha256(
priv.schnorrNonce.bytes ++ CryptoUtil.serializeForHash(label),
signingVersion.nonceTag)
val tweak = ECPrivateKey(hash.bytes)
priv.add(tweak)
}
def listEventDbs(): Future[Vector[EventDb]] = eventDAO.findAll()
def listPendingEventDbs(): Future[Vector[EventDb]] = eventDAO.getPendingEvents
def listEvents(): Future[Vector[Event]] = {
for {
rValDbs <- rValueDAO.findAll()
eventDbs <- eventDAO.findAll()
outcomes <- eventOutcomeDAO.findAll()
} yield {
val rValDbsByNonce = rValDbs.groupBy(_.nonce)
val outcomesByNonce = outcomes.groupBy(_.nonce)
eventDbs.map(db =>
Event(rValDbsByNonce(db.nonce).head, db, outcomesByNonce(db.nonce)))
}
}
def getEvent(nonce: SchnorrNonce): Future[Option[Event]] = {
for {
rValDbOpt <- rValueDAO.read(nonce)
eventDbOpt <- eventDAO.read(nonce)
outcomes <- eventOutcomeDAO.findByNonce(nonce)
} yield {
(rValDbOpt, eventDbOpt) match {
case (Some(rValDb), Some(eventDb)) =>
Some(Event(rValDb, eventDb, outcomes))
case (None, None) | (Some(_), None) | (None, Some(_)) =>
None
}
}
}
def createNewEvent(
eventName: String,
maturationTime: Instant,
outcomes: Vector[String]): Future[EventDb] = {
for {
indexOpt <- rValueDAO.maxKeyIndex
index = indexOpt match {
case Some(value) => value + 1
case None => 0
}
signingVersion = SigningVersion.latest
path = getPath(index)
nonce = getKValue(eventName, path, signingVersion).schnorrNonce
hash = CryptoUtil.taggedSha256(
nonce.bytes ++ CryptoUtil.serializeForHash(eventName),
signingVersion.commitmentTag)
commitmentSig = signingKey.schnorrSign(hash.bytes)
rValueDb = RValueDbHelper(nonce = nonce,
eventName = eventName,
account = rValAccount,
chainType = rValueChainIndex,
keyIndex = index,
commitmentSignature = commitmentSig)
eventDb = EventDb(nonce,
publicKey,
eventName,
outcomes.size,
signingVersion,
maturationTime,
None)
eventOutcomeDbs = outcomes.map { outcome =>
val hash = CryptoUtil.taggedSha256(outcome, signingVersion.outcomeTag)
EventOutcomeDb(nonce, outcome, hash)
}
_ <- rValueDAO.create(rValueDb)
eventDb <- eventDAO.create(eventDb)
_ <- eventOutcomeDAO.createAll(eventOutcomeDbs)
} yield eventDb
}
def signEvent(nonce: SchnorrNonce, outcome: String): Future[EventDb] = {
for {
rValDbOpt <- rValueDAO.read(nonce)
rValDb <- rValDbOpt match {
case Some(value) => Future.successful(value)
case None =>
Future.failed(
new RuntimeException(
s"Nonce not found from this oracle ${nonce.hex}"))
}
eventOpt <- eventDAO.read(nonce)
eventDb <- eventOpt match {
case Some(value) =>
require(
value.attestationOpt.isEmpty,
s"Event already has been signed, attestation: ${value.attestationOpt.get}")
Future.successful(value)
case None =>
Future.failed(
new RuntimeException(
s"No event saved with nonce ${nonce.hex} $outcome"))
}
eventOutcomeOpt <- eventOutcomeDAO.read((nonce, outcome))
eventOutcomeDb <- eventOutcomeOpt match {
case Some(value) => Future.successful(value)
case None =>
Future.failed(new RuntimeException(
s"No event outcome saved with nonce and message ${nonce.hex} $outcome"))
}
sig = eventDb.signingVersion match {
case Mock =>
val kVal = getKValue(rValDb, Mock)
require(kVal.schnorrNonce == rValDb.nonce,
"The nonce from derived seed did not match database")
signingKey.schnorrSignWithNonce(eventOutcomeDb.hashedMessage.bytes,
kVal)
}
updated = eventDb.copy(attestationOpt = Some(sig.sig))
_ <- eventDAO.update(updated)
} yield updated
}
}
object DLCOracle {
def apply(
mnemonicCode: MnemonicCode,
password: AesPassword,
bip39PasswordOpt: Option[String] = None)(implicit
conf: DLCOracleAppConfig): DLCOracle = {
val decryptedMnemonic = DecryptedMnemonic(mnemonicCode, TimeUtil.now)
val encrypted = decryptedMnemonic.encrypt(password)
if (!conf.seedExists()) {
WalletStorage.writeMnemonicToDisk(conf.seedPath, encrypted)
}
val key =
WalletStorage.getPrivateKeyFromDisk(conf.seedPath,
SegWitMainNetPriv,
password,
bip39PasswordOpt)
DLCOracle(key)
}
}

View file

@ -4,10 +4,14 @@ import java.nio.file.{Files, Path}
import com.typesafe.config.Config
import org.bitcoins.core.config.NetworkParameters
import org.bitcoins.core.util.FutureUtil
import org.bitcoins.core.crypto.ExtKeyVersion.SegWitMainNetPriv
import org.bitcoins.core.crypto.MnemonicCode
import org.bitcoins.core.util.{FutureUtil, TimeUtil}
import org.bitcoins.crypto.AesPassword
import org.bitcoins.db.DatabaseDriver._
import org.bitcoins.db.{AppConfig, DbManagement, JdbcProfileComponent}
import org.bitcoins.dlc.oracle.storage._
import org.bitcoins.keymanager.WalletStorage
import org.bitcoins.keymanager.{DecryptedMnemonic, WalletStorage}
import scala.concurrent.{ExecutionContext, Future}
@ -46,9 +50,7 @@ case class DLCOracleAppConfig(
Files.createDirectories(datadir)
}
val numMigrations = {
migrate()
}
val numMigrations = migrate()
logger.info(s"Applied $numMigrations to the wallet project")
@ -61,8 +63,36 @@ case class DLCOracleAppConfig(
}
def exists(): Boolean = {
seedExists() &&
Files.exists(baseDatadir.resolve("oracle.sqlite"))
lazy val hasDb = this.driver match {
case PostgreSQL => true
case SQLite =>
Files.exists(baseDatadir.resolve("oracle.sqlite"))
}
seedExists() && hasDb
}
def initialize(oracle: DLCOracle): Future[DLCOracle] = {
start().map(_ => oracle)
}
def initialize(
password: AesPassword,
bip39PasswordOpt: Option[String] = None): Future[DLCOracle] = {
if (!seedExists()) {
val entropy = MnemonicCode.getEntropy256Bits
val mnemonicCode = MnemonicCode.fromEntropy(entropy)
val decryptedMnemonic = DecryptedMnemonic(mnemonicCode, TimeUtil.now)
val encrypted = decryptedMnemonic.encrypt(password)
WalletStorage.writeMnemonicToDisk(seedPath, encrypted)
}
val key =
WalletStorage.getPrivateKeyFromDisk(seedPath,
SegWitMainNetPriv,
password,
bip39PasswordOpt)
val oracle = DLCOracle(key)(this)
initialize(oracle)
}
private val rValueTable: TableQuery[Table[_]] = {

View file

@ -1,7 +1,9 @@
package org.bitcoins.dlc.oracle.storage
import java.time.Instant
import org.bitcoins.commons.jsonmodels.dlc.SigningVersion
import org.bitcoins.crypto.{FieldElement, SchnorrNonce}
import org.bitcoins.crypto._
import org.bitcoins.db.{AppConfig, CRUD, DbCommonsColumnMappers, SlickUtil}
import slick.lifted.{ForeignKeyQuery, ProvenShape}
@ -43,19 +45,25 @@ case class EventDAO()(implicit
def nonce: Rep[SchnorrNonce] = column("nonce", O.PrimaryKey)
def label: Rep[String] = column("label", O.Unique)
def pubkey: Rep[SchnorrPublicKey] = column("pubkey")
def eventName: Rep[String] = column("event_name", O.Unique)
def numOutcomes: Rep[Long] = column("num_outcomes")
def signingVersion: Rep[SigningVersion] = column("signing_version")
def maturationTime: Rep[Instant] = column("maturation_time")
def attestationOpt: Rep[Option[FieldElement]] = column("attestation")
def * : ProvenShape[EventDb] =
(nonce,
label,
pubkey,
eventName,
numOutcomes,
signingVersion,
maturationTime,
attestationOpt) <> (EventDb.tupled, EventDb.unapply)
def fk: ForeignKeyQuery[_, RValueDb] = {
@ -66,8 +74,8 @@ case class EventDAO()(implicit
def fkLabel: ForeignKeyQuery[_, RValueDb] = {
foreignKey("fk_label",
sourceColumns = label,
targetTableQuery = rValueTable)(_.label)
sourceColumns = eventName,
targetTableQuery = rValueTable)(_.eventName)
}
}
}

View file

@ -1,13 +1,17 @@
package org.bitcoins.dlc.oracle.storage
import java.time.Instant
import org.bitcoins.commons.jsonmodels.dlc.SigningVersion
import org.bitcoins.crypto.{FieldElement, SchnorrDigitalSignature, SchnorrNonce}
import org.bitcoins.crypto._
case class EventDb(
nonce: SchnorrNonce,
label: String,
pubkey: SchnorrPublicKey,
eventName: String,
numOutcomes: Long,
signingVersion: SigningVersion,
maturationTime: Instant,
attestationOpt: Option[FieldElement]) {
lazy val sigOpt: Option[SchnorrDigitalSignature] =

View file

@ -43,7 +43,7 @@ case class RValueDAO()(implicit
def nonce: Rep[SchnorrNonce] = column("nonce", O.PrimaryKey)
def label: Rep[String] = column("label", O.Unique)
def eventName: Rep[String] = column("event_name", O.Unique)
def purpose: Rep[HDPurpose] = column("hd_purpose")
@ -53,14 +53,14 @@ case class RValueDAO()(implicit
def chainType: Rep[Int] = column("chain_type")
def keyIndex: Rep[Int] = column("key_index")
def keyIndex: Rep[Int] = column("key_index", O.Unique)
def commitmentSignature: Rep[SchnorrDigitalSignature] =
column("commitment_signature")
def * : ProvenShape[RValueDb] =
(nonce,
label,
eventName,
purpose,
coinType,
accountIndex,

View file

@ -5,7 +5,7 @@ import org.bitcoins.crypto.{SchnorrDigitalSignature, SchnorrNonce}
case class RValueDb(
nonce: SchnorrNonce,
label: String,
eventName: String,
purpose: HDPurpose,
accountCoin: HDCoinType,
accountIndex: Int,
@ -21,13 +21,13 @@ object RValueDbHelper {
def apply(
nonce: SchnorrNonce,
label: String,
eventName: String,
account: HDAccount,
chainType: Int,
keyIndex: Int,
commitmentSignature: SchnorrDigitalSignature): RValueDb = {
RValueDb(nonce,
label,
eventName,
account.purpose,
account.coin.coinType,
account.index,

View file

@ -0,0 +1,30 @@
package org.bitcoins.testkit.fixtures
import org.bitcoins.crypto.AesPassword
import org.bitcoins.dlc.oracle.{DLCOracle, DLCOracleAppConfig}
import org.bitcoins.testkit.BitcoinSTestAppConfig.tmpDir
import org.bitcoins.testkit.util.FileUtil
import org.scalatest._
import scala.concurrent.Future
trait DLCOracleFixture extends BitcoinSFixture {
override type FixtureParam = DLCOracle
override def withFixture(test: OneArgAsyncTest): FutureOutcome = {
val builder: () => Future[DLCOracle] = () => {
val conf = DLCOracleAppConfig(tmpDir())
conf.initialize(AesPassword.fromString("Ben was here"), None)
}
val destroy: DLCOracle => Future[Unit] = dlcOracle => {
val conf = dlcOracle.conf
conf.dropAll().flatMap { _ =>
FileUtil.deleteTmpDir(conf.baseDatadir)
conf.stop()
}
}
makeDependentFixture(builder, destroy = destroy)(test)
}
}