lnwallet: update internal wallet reservations to use revoke keys

This update the wallet to implement the new single funder workflow
which uses revocation keys rather than revocation hashes for the
commitment transactions.
This commit is contained in:
Olaoluwa Osuntokun 2016-06-30 12:13:46 -07:00
parent 1b490c52ed
commit 2c187209eb
No known key found for this signature in database
GPG Key ID: 9CC5B105D03521A2
2 changed files with 80 additions and 74 deletions

View File

@ -168,10 +168,15 @@ type addCounterPartySigsMsg struct {
type addSingleFunderSigsMsg struct { type addSingleFunderSigsMsg struct {
pendingFundingID uint64 pendingFundingID uint64
// fundingOutpoint is the out point of the completed funding // fundingOutpoint is the outpoint of the completed funding
// transaction as assembled by the workflow initiator. // transaction as assembled by the workflow initiator.
fundingOutpoint *wire.OutPoint fundingOutpoint *wire.OutPoint
// revokeKey is the revocation public key derived by the remote node to
// be used within the initial version of the commitment transaction we
// construct for them.
revokeKey *btcec.PublicKey
// This should be 1/2 of the signatures needed to succesfully spend our // This should be 1/2 of the signatures needed to succesfully spend our
// version of the commitment transaction. // version of the commitment transaction.
theirCommitmentSig []byte theirCommitmentSig []byte
@ -525,7 +530,8 @@ func (l *LightningWallet) handleFundingReserveRequest(req *initFundingReserveMsg
// don't need to perform any coin selection. Otherwise, attempt to // don't need to perform any coin selection. Otherwise, attempt to
// obtain enough coins to meet the required funding amount. // obtain enough coins to meet the required funding amount.
if req.fundingAmount != 0 { if req.fundingAmount != 0 {
if err := l.selectCoinsAndChange(req.fundingAmount, ourContribution); err != nil { if err := l.selectCoinsAndChange(req.fundingAmount,
ourContribution); err != nil {
req.err <- err req.err <- err
req.resp <- nil req.resp <- nil
return return
@ -570,22 +576,6 @@ func (l *LightningWallet) handleFundingReserveRequest(req *initFundingReserveMsg
reservation.partialState.OurDeliveryScript = deliveryScript reservation.partialState.OurDeliveryScript = deliveryScript
ourContribution.DeliveryAddress = deliveryAddress ourContribution.DeliveryAddress = deliveryAddress
// Create a new elkrem for verifiable transaction revocations. This
// will be used to generate revocation hashes for our past/current
// commitment transactions once we start to make payments within the
// channel.
// TODO(roabeef): should be HMAC based...REMOVE BEFORE ALPHA
var zero wire.ShaHash
elkremSender := elkrem.NewElkremSender(63, zero)
reservation.partialState.LocalElkrem = &elkremSender
firstPrimage, err := elkremSender.AtIndex(0)
if err != nil {
req.err <- err
req.resp <- nil
return
}
copy(ourContribution.RevocationHash[:], btcutil.Hash160(firstPrimage[:]))
// Funding reservation request succesfully handled. The funding inputs // Funding reservation request succesfully handled. The funding inputs
// will be marked as unavailable until the reservation is either // will be marked as unavailable until the reservation is either
// completed, or cancecled. // completed, or cancecled.
@ -801,21 +791,24 @@ func (l *LightningWallet) handleContributionMsg(req *addContributionMsg) {
// Initialize an empty sha-chain for them, tracking the current pending // Initialize an empty sha-chain for them, tracking the current pending
// revocation hash (we don't yet know the pre-image so we can't add it // revocation hash (we don't yet know the pre-image so we can't add it
// to the chain). // to the chain).
e := elkrem.NewElkremReceiver(63) e := &elkrem.ElkremReceiver{}
// TODO(roasbeef): this is incorrect!! fix before lnstate integration pendingReservation.partialState.RemoteElkrem = e
var zero wire.ShaHash pendingReservation.partialState.TheirCurrentRevocation = theirContribution.RevocationKey
if err := e.AddNext(&zero); err != nil {
// Now that we have their commitment key, we can create the revocation
// key for the first version of our commitment transaction. To do so,
// we'll first create our elkrem root, then grab the first pre-iamge
// from it.
elkremRoot := deriveElkremRoot(ourKey, theirKey)
elkremSender := elkrem.NewElkremSender(elkremRoot)
pendingReservation.partialState.LocalElkrem = elkremSender
firstPreimage, err := elkremSender.AtIndex(0)
if err != nil {
req.err <- err req.err <- err
return return
} }
theirCommitKey := theirContribution.CommitKey
pendingReservation.partialState.RemoteElkrem = &e ourRevokeKey := deriveRevocationPubkey(theirCommitKey, firstPreimage[:])
pendingReservation.partialState.TheirCurrentRevocation = theirContribution.RevocationHash
// Grab the hash of the current pre-image in our chain, this is needed
// for our commitment tx.
// TODO(roasbeef): grab partial state above to avoid long attr chain
ourCurrentRevokeHash := pendingReservation.ourContribution.RevocationHash
// Create the txIn to our commitment transaction; required to construct // Create the txIn to our commitment transaction; required to construct
// the commitment transactions. // the commitment transactions.
@ -824,19 +817,18 @@ func (l *LightningWallet) handleContributionMsg(req *addContributionMsg) {
// With the funding tx complete, create both commitment transactions. // With the funding tx complete, create both commitment transactions.
// TODO(roasbeef): much cleanup + de-duplication // TODO(roasbeef): much cleanup + de-duplication
pendingReservation.fundingLockTime = theirContribution.CsvDelay pendingReservation.fundingLockTime = theirContribution.CsvDelay
ourCommitKey := ourContribution.CommitKey
theirCommitKey := theirContribution.CommitKey
ourBalance := ourContribution.FundingAmount ourBalance := ourContribution.FundingAmount
theirBalance := theirContribution.FundingAmount theirBalance := theirContribution.FundingAmount
ourCommitKey := ourContribution.CommitKey
ourCommitTx, err := createCommitTx(fundingTxIn, ourCommitKey, theirCommitKey, ourCommitTx, err := createCommitTx(fundingTxIn, ourCommitKey, theirCommitKey,
ourCurrentRevokeHash[:], ourContribution.CsvDelay, ourRevokeKey, ourContribution.CsvDelay,
ourBalance, theirBalance) ourBalance, theirBalance)
if err != nil { if err != nil {
req.err <- err req.err <- err
return return
} }
theirCommitTx, err := createCommitTx(fundingTxIn, theirCommitKey, ourCommitKey, theirCommitTx, err := createCommitTx(fundingTxIn, theirCommitKey, ourCommitKey,
theirContribution.RevocationHash[:], theirContribution.CsvDelay, theirContribution.RevocationKey, theirContribution.CsvDelay,
theirBalance, ourBalance) theirBalance, ourBalance)
if err != nil { if err != nil {
req.err <- err req.err <- err
@ -863,6 +855,7 @@ func (l *LightningWallet) handleContributionMsg(req *addContributionMsg) {
pendingReservation.partialState.TheirMultiSigKey = theirContribution.MultiSigKey pendingReservation.partialState.TheirMultiSigKey = theirContribution.MultiSigKey
pendingReservation.partialState.TheirCommitTx = theirCommitTx pendingReservation.partialState.TheirCommitTx = theirCommitTx
pendingReservation.partialState.OurCommitTx = ourCommitTx pendingReservation.partialState.OurCommitTx = ourCommitTx
pendingReservation.ourContribution.RevocationKey = ourRevokeKey
// Generate a signature for their version of the initial commitment // Generate a signature for their version of the initial commitment
// transaction. // transaction.
@ -904,10 +897,10 @@ func (l *LightningWallet) handleSingleContribution(req *addSingleContributionMsg
// Additionally, we can now also record the redeem script of the // Additionally, we can now also record the redeem script of the
// funding transaction. // funding transaction.
// TODO(roasbeef): switch to proper pubkey derivation // TODO(roasbeef): switch to proper pubkey derivation
ourKey := pendingReservation.partialState.OurMultiSigKey.PubKey() ourKey := pendingReservation.partialState.OurMultiSigKey
theirKey := theirContribution.MultiSigKey theirKey := theirContribution.MultiSigKey
channelCapacity := int64(pendingReservation.partialState.Capacity) channelCapacity := int64(pendingReservation.partialState.Capacity)
redeemScript, _, err := genFundingPkScript(ourKey.SerializeCompressed(), redeemScript, _, err := genFundingPkScript(ourKey.PubKey().SerializeCompressed(),
theirKey.SerializeCompressed(), channelCapacity) theirKey.SerializeCompressed(), channelCapacity)
if err != nil { if err != nil {
req.err <- err req.err <- err
@ -915,16 +908,24 @@ func (l *LightningWallet) handleSingleContribution(req *addSingleContributionMsg
} }
pendingReservation.partialState.FundingRedeemScript = redeemScript pendingReservation.partialState.FundingRedeemScript = redeemScript
// Initialize an empty sha-chain for them, tracking the current pending // Now that we know their commitment key, we can create the revocation
// revocation hash (we don't yet know the pre-image so we can't add it // key for our version of the initial commitment transaction.
// to the chain). elkremRoot := deriveElkremRoot(ourKey, theirKey)
e := elkrem.NewElkremReceiver(63) elkremSender := elkrem.NewElkremSender(elkremRoot)
// TODO(roasbeef): this is incorrect!! fix before lnstate integration firstPreimage, err := elkremSender.AtIndex(0)
var zero wire.ShaHash if err != nil {
if err := e.AddNext(&zero); err != nil {
req.err <- err req.err <- err
return return
} }
pendingReservation.partialState.LocalElkrem = elkremSender
theirCommitKey := theirContribution.CommitKey
ourRevokeKey := deriveRevocationPubkey(theirCommitKey, firstPreimage[:])
// Initialize an empty sha-chain for them, tracking the current pending
// revocation hash (we don't yet know the pre-image so we can't add it
// to the chain).
remoteElkrem := &elkrem.ElkremReceiver{}
pendingReservation.partialState.RemoteElkrem = remoteElkrem
// Record the counterpaty's remaining contributions to the channel, // Record the counterpaty's remaining contributions to the channel,
// converting their delivery address into a public key script. // converting their delivery address into a public key script.
@ -935,10 +936,9 @@ func (l *LightningWallet) handleSingleContribution(req *addSingleContributionMsg
} }
pendingReservation.partialState.RemoteCsvDelay = theirContribution.CsvDelay pendingReservation.partialState.RemoteCsvDelay = theirContribution.CsvDelay
pendingReservation.partialState.TheirDeliveryScript = deliveryScript pendingReservation.partialState.TheirDeliveryScript = deliveryScript
pendingReservation.partialState.RemoteElkrem = &e
pendingReservation.partialState.TheirCommitKey = theirContribution.CommitKey pendingReservation.partialState.TheirCommitKey = theirContribution.CommitKey
pendingReservation.partialState.TheirMultiSigKey = theirContribution.MultiSigKey pendingReservation.partialState.TheirMultiSigKey = theirContribution.MultiSigKey
pendingReservation.partialState.TheirCurrentRevocation = theirContribution.RevocationHash pendingReservation.ourContribution.RevocationKey = ourRevokeKey
req.err <- nil req.err <- nil
return return
@ -1115,6 +1115,7 @@ func (l *LightningWallet) handleSingleFunderSigs(req *addSingleFunderSigsMsg) {
defer pendingReservation.Unlock() defer pendingReservation.Unlock()
pendingReservation.partialState.FundingOutpoint = req.fundingOutpoint pendingReservation.partialState.FundingOutpoint = req.fundingOutpoint
pendingReservation.partialState.TheirCurrentRevocation = req.revokeKey
pendingReservation.partialState.ChanID = req.fundingOutpoint pendingReservation.partialState.ChanID = req.fundingOutpoint
fundingTxIn := wire.NewTxIn(req.fundingOutpoint, nil, nil) fundingTxIn := wire.NewTxIn(req.fundingOutpoint, nil, nil)
@ -1126,15 +1127,15 @@ func (l *LightningWallet) handleSingleFunderSigs(req *addSingleFunderSigsMsg) {
ourBalance := pendingReservation.ourContribution.FundingAmount ourBalance := pendingReservation.ourContribution.FundingAmount
theirBalance := pendingReservation.theirContribution.FundingAmount theirBalance := pendingReservation.theirContribution.FundingAmount
ourCommitTx, err := createCommitTx(fundingTxIn, ourCommitKey, theirCommitKey, ourCommitTx, err := createCommitTx(fundingTxIn, ourCommitKey, theirCommitKey,
pendingReservation.ourContribution.RevocationHash[:], pendingReservation.ourContribution.RevocationKey,
pendingReservation.ourContribution.CsvDelay, ourBalance, theirBalance) pendingReservation.ourContribution.CsvDelay, ourBalance, theirBalance)
if err != nil { if err != nil {
req.err <- err req.err <- err
return return
} }
theirCommitTx, err := createCommitTx(fundingTxIn, theirCommitKey, ourCommitKey, theirCommitTx, err := createCommitTx(fundingTxIn, theirCommitKey, ourCommitKey,
pendingReservation.theirContribution.RevocationHash[:], req.revokeKey, pendingReservation.theirContribution.CsvDelay,
pendingReservation.theirContribution.CsvDelay, theirBalance, ourBalance) theirBalance, ourBalance)
if err != nil { if err != nil {
req.err <- err req.err <- err
return return

View File

@ -108,7 +108,8 @@ type bobNode struct {
// Contribution returns bobNode's contribution necessary to open a payment // Contribution returns bobNode's contribution necessary to open a payment
// channel with Alice. // channel with Alice.
func (b *bobNode) Contribution() *ChannelContribution { func (b *bobNode) Contribution(aliceCommitKey *btcec.PublicKey) *ChannelContribution {
revokeKey := deriveRevocationPubkey(aliceCommitKey, b.revocation[:])
return &ChannelContribution{ return &ChannelContribution{
FundingAmount: b.fundingAmt, FundingAmount: b.fundingAmt,
Inputs: b.availableOutputs, Inputs: b.availableOutputs,
@ -116,20 +117,21 @@ func (b *bobNode) Contribution() *ChannelContribution {
MultiSigKey: b.channelKey, MultiSigKey: b.channelKey,
CommitKey: b.channelKey, CommitKey: b.channelKey,
DeliveryAddress: b.deliveryAddress, DeliveryAddress: b.deliveryAddress,
RevocationHash: b.revocation, RevocationKey: revokeKey,
CsvDelay: b.delay, CsvDelay: b.delay,
} }
} }
// SingleContribution returns bobNode's contribution to a single funded // SingleContribution returns bobNode's contribution to a single funded
// channel. This contribution contains no inputs nor change outputs. // channel. This contribution contains no inputs nor change outputs.
func (b *bobNode) SingleContribution() *ChannelContribution { func (b *bobNode) SingleContribution(aliceCommitKey *btcec.PublicKey) *ChannelContribution {
revokeKey := deriveRevocationPubkey(aliceCommitKey, b.revocation[:])
return &ChannelContribution{ return &ChannelContribution{
FundingAmount: b.fundingAmt, FundingAmount: b.fundingAmt,
MultiSigKey: b.channelKey, MultiSigKey: b.channelKey,
CommitKey: b.channelKey, CommitKey: b.channelKey,
DeliveryAddress: b.deliveryAddress, DeliveryAddress: b.deliveryAddress,
RevocationHash: b.revocation, RevocationKey: revokeKey,
CsvDelay: b.delay, CsvDelay: b.delay,
} }
} }
@ -390,17 +392,15 @@ func testDualFundingReservationWorkflow(miner *rpctest.Harness, lnwallet *Lightn
if ourContribution.DeliveryAddress == nil { if ourContribution.DeliveryAddress == nil {
t.Fatalf("alice's final delivery address not found") t.Fatalf("alice's final delivery address not found")
} }
if bytes.Equal(ourContribution.RevocationHash[:], zeroHash) {
t.Fatalf("alice's revocation hash not found")
}
if ourContribution.CsvDelay == 0 { if ourContribution.CsvDelay == 0 {
t.Fatalf("csv delay not set") t.Fatalf("csv delay not set")
} }
// Bob sends over his output, change addr, pub keys, initial revocation, // Bob sends over his output, change addr, pub keys, initial revocation,
// final delivery address, and his accepted csv delay for the commitmen // final delivery address, and his accepted csv delay for the
// t transactions. // commitment transactions.
if err := chanReservation.ProcessContribution(bobNode.Contribution()); err != nil { bobContribution := bobNode.Contribution(ourContribution.CommitKey)
if err := chanReservation.ProcessContribution(bobContribution); err != nil {
t.Fatalf("unable to add bob's funds to the funding tx: %v", err) t.Fatalf("unable to add bob's funds to the funding tx: %v", err)
} }
@ -415,6 +415,9 @@ func testDualFundingReservationWorkflow(miner *rpctest.Harness, lnwallet *Lightn
if ourCommitSig == nil { if ourCommitSig == nil {
t.Fatalf("commitment sig not found") t.Fatalf("commitment sig not found")
} }
if ourContribution.RevocationKey == nil {
t.Fatalf("alice's revocation key not found")
}
// Additionally, the funding tx should have been populated. // Additionally, the funding tx should have been populated.
if chanReservation.fundingTx == nil { if chanReservation.fundingTx == nil {
t.Fatalf("funding transaction never created!") t.Fatalf("funding transaction never created!")
@ -438,8 +441,8 @@ func testDualFundingReservationWorkflow(miner *rpctest.Harness, lnwallet *Lightn
if theirContribution.DeliveryAddress == nil { if theirContribution.DeliveryAddress == nil {
t.Fatalf("bob's final delivery address not found") t.Fatalf("bob's final delivery address not found")
} }
if bytes.Equal(theirContribution.RevocationHash[:], zeroHash) { if theirContribution.RevocationKey == nil {
t.Fatalf("bob's revocaiton hash not found") t.Fatalf("bob's revocaiton key not found")
} }
// Alice responds with her output, change addr, multi-sig key and signatures. // Alice responds with her output, change addr, multi-sig key and signatures.
@ -667,9 +670,6 @@ func testSingleFunderReservationWorkflowInitiator(miner *rpctest.Harness, lnwall
if ourContribution.DeliveryAddress == nil { if ourContribution.DeliveryAddress == nil {
t.Fatalf("alice's final delivery address not found") t.Fatalf("alice's final delivery address not found")
} }
if bytes.Equal(ourContribution.RevocationHash[:], zeroHash) {
t.Fatalf("alice's revocation hash not found")
}
if ourContribution.CsvDelay == 0 { if ourContribution.CsvDelay == 0 {
t.Fatalf("csv delay not set") t.Fatalf("csv delay not set")
} }
@ -677,7 +677,8 @@ func testSingleFunderReservationWorkflowInitiator(miner *rpctest.Harness, lnwall
// At this point bob now responds to our request with a response // At this point bob now responds to our request with a response
// containing his channel contribution. The contribution will have no // containing his channel contribution. The contribution will have no
// inputs, only a multi-sig key, csv delay, etc. // inputs, only a multi-sig key, csv delay, etc.
if err := chanReservation.ProcessContribution(bobNode.SingleContribution()); err != nil { bobContribution := bobNode.SingleContribution(ourContribution.CommitKey)
if err := chanReservation.ProcessContribution(bobContribution); err != nil {
t.Fatalf("unable to add bob's contribution: %v", err) t.Fatalf("unable to add bob's contribution: %v", err)
} }
@ -705,6 +706,9 @@ func testSingleFunderReservationWorkflowInitiator(miner *rpctest.Harness, lnwall
t.Fatalf("bob shouldn't have any change outputs, instead "+ t.Fatalf("bob shouldn't have any change outputs, instead "+
"has %v", theirContribution.ChangeOutputs[0].Value) "has %v", theirContribution.ChangeOutputs[0].Value)
} }
if ourContribution.RevocationKey == nil {
t.Fatalf("alice's revocation hash not found")
}
if theirContribution.MultiSigKey == nil { if theirContribution.MultiSigKey == nil {
t.Fatalf("bob's key for multi-sig not found") t.Fatalf("bob's key for multi-sig not found")
} }
@ -714,7 +718,7 @@ func testSingleFunderReservationWorkflowInitiator(miner *rpctest.Harness, lnwall
if theirContribution.DeliveryAddress == nil { if theirContribution.DeliveryAddress == nil {
t.Fatalf("bob's final delivery address not found") t.Fatalf("bob's final delivery address not found")
} }
if bytes.Equal(theirContribution.RevocationHash[:], zeroHash) { if theirContribution.RevocationKey == nil {
t.Fatalf("bob's revocaiton hash not found") t.Fatalf("bob's revocaiton hash not found")
} }
@ -798,9 +802,6 @@ func testSingleFunderReservationWorkflowResponder(miner *rpctest.Harness, lnwall
if ourContribution.DeliveryAddress == nil { if ourContribution.DeliveryAddress == nil {
t.Fatalf("alice's final delivery address not found") t.Fatalf("alice's final delivery address not found")
} }
if bytes.Equal(ourContribution.RevocationHash[:], zeroHash) {
t.Fatalf("alice's revocation hash not found")
}
if ourContribution.CsvDelay == 0 { if ourContribution.CsvDelay == 0 {
t.Fatalf("csv delay not set") t.Fatalf("csv delay not set")
} }
@ -808,7 +809,7 @@ func testSingleFunderReservationWorkflowResponder(miner *rpctest.Harness, lnwall
// Next we process Bob's single funder contribution which doesn't // Next we process Bob's single funder contribution which doesn't
// include any inputs or change addresses, as only Bob will construct // include any inputs or change addresses, as only Bob will construct
// the funding transaction. // the funding transaction.
bobContribution := bobNode.Contribution() bobContribution := bobNode.Contribution(ourContribution.CommitKey)
if err := chanReservation.ProcessSingleContribution(bobContribution); err != nil { if err := chanReservation.ProcessSingleContribution(bobContribution); err != nil {
t.Fatalf("unable to process bob's contribution: %v", err) t.Fatalf("unable to process bob's contribution: %v", err)
} }
@ -819,6 +820,9 @@ func testSingleFunderReservationWorkflowResponder(miner *rpctest.Harness, lnwall
t.Fatalf("bob shouldn't have one inputs, instead has %v", t.Fatalf("bob shouldn't have one inputs, instead has %v",
len(bobContribution.Inputs)) len(bobContribution.Inputs))
} }
if ourContribution.RevocationKey == nil {
t.Fatalf("alice's revocation key not found")
}
if len(bobContribution.ChangeOutputs) != 1 { if len(bobContribution.ChangeOutputs) != 1 {
t.Fatalf("bob shouldn't have one change output, instead "+ t.Fatalf("bob shouldn't have one change output, instead "+
"has %v", len(bobContribution.ChangeOutputs)) "has %v", len(bobContribution.ChangeOutputs))
@ -832,8 +836,8 @@ func testSingleFunderReservationWorkflowResponder(miner *rpctest.Harness, lnwall
if bobContribution.DeliveryAddress == nil { if bobContribution.DeliveryAddress == nil {
t.Fatalf("bob's final delivery address not found") t.Fatalf("bob's final delivery address not found")
} }
if bytes.Equal(bobContribution.RevocationHash[:], zeroHash) { if bobContribution.RevocationKey == nil {
t.Fatalf("bob's revocaiton hash not found") t.Fatalf("bob's revocaiton key not found")
} }
fundingRedeemScript, multiOut, err := genFundingPkScript( fundingRedeemScript, multiOut, err := genFundingPkScript(
@ -865,7 +869,7 @@ func testSingleFunderReservationWorkflowResponder(miner *rpctest.Harness, lnwall
fundingTxIn := wire.NewTxIn(fundingOutpoint, nil, nil) fundingTxIn := wire.NewTxIn(fundingOutpoint, nil, nil)
aliceCommitTx, err := createCommitTx(fundingTxIn, ourContribution.CommitKey, aliceCommitTx, err := createCommitTx(fundingTxIn, ourContribution.CommitKey,
bobContribution.CommitKey, ourContribution.RevocationHash[:], bobContribution.CommitKey, ourContribution.RevocationKey,
ourContribution.CsvDelay, 0, capacity) ourContribution.CsvDelay, 0, capacity)
if err != nil { if err != nil {
t.Fatalf("unable to create alice's commit tx: %v", err) t.Fatalf("unable to create alice's commit tx: %v", err)
@ -878,8 +882,9 @@ func testSingleFunderReservationWorkflowResponder(miner *rpctest.Harness, lnwall
} }
// With this stage complete, Alice can now complete the reservation. // With this stage complete, Alice can now complete the reservation.
if err := chanReservation.CompleteReservationSingle(fundingOutpoint, bobRevokeKey := bobContribution.RevocationKey
bobCommitSig); err != nil { if err := chanReservation.CompleteReservationSingle(bobRevokeKey,
fundingOutpoint, bobCommitSig); err != nil {
t.Fatalf("unable to complete reservation: %v", err) t.Fatalf("unable to complete reservation: %v", err)
} }