diff --git a/go.mod b/go.mod index e93a82f..11385c4 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.23.0 require ( go.etcd.io/bbolt v1.3.11 - go.sia.tech/core v0.4.8-0.20241003192046-425f95763c90 + go.sia.tech/core v0.4.8-0.20241007234521-ea8efbfea700 go.sia.tech/mux v1.3.0 go.uber.org/zap v1.27.0 golang.org/x/crypto v0.27.0 diff --git a/go.sum b/go.sum index f5b7a6e..e01a9c6 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,10 @@ go.etcd.io/bbolt v1.3.11 h1:yGEzV1wPz2yVCLsD8ZAiGHhHVlczyC9d1rP43/VCRJ0= go.etcd.io/bbolt v1.3.11/go.mod h1:dksAq7YMXoljX0xu6VF5DMZGbhYYoLUalEiSySYAS4I= go.sia.tech/core v0.4.8-0.20241003192046-425f95763c90 h1:R/G7XXyzLKelfGBGT6XQpih379v5jJx6T4AsjLwyjJc= go.sia.tech/core v0.4.8-0.20241003192046-425f95763c90/go.mod h1:j2Ke8ihV8or7d2VDrFZWcCkwSVHO0DNMQJAGs9Qop2M= +go.sia.tech/core v0.4.8-0.20241007234354-9a5ba0c39b78 h1:jeYj562vvomoojUFR53Gdky/tkCIboTBR9/8Kf11rBE= +go.sia.tech/core v0.4.8-0.20241007234354-9a5ba0c39b78/go.mod h1:j2Ke8ihV8or7d2VDrFZWcCkwSVHO0DNMQJAGs9Qop2M= +go.sia.tech/core v0.4.8-0.20241007234521-ea8efbfea700 h1:LUaaExNKRMdWEuzaHrSbjrMNkE5yTykuw9lXLXIZHYg= +go.sia.tech/core v0.4.8-0.20241007234521-ea8efbfea700/go.mod h1:j2Ke8ihV8or7d2VDrFZWcCkwSVHO0DNMQJAGs9Qop2M= go.sia.tech/mux v1.3.0 h1:hgR34IEkqvfBKUJkAzGi31OADeW2y7D6Bmy/Jcbop9c= go.sia.tech/mux v1.3.0/go.mod h1:I46++RD4beqA3cW9Xm9SwXbezwPqLvHhVs9HLpDtt58= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= diff --git a/rhp/v4/rpc.go b/rhp/v4/rpc.go index 886c749..ed77478 100644 --- a/rhp/v4/rpc.go +++ b/rhp/v4/rpc.go @@ -163,6 +163,13 @@ type ( RenewalSet TransactionSet `json:"renewalSet"` Cost types.Currency `json:"cost"` } + + // RPCRefreshContractResult contains the result of executing the refresh contract RPC. + RPCRefreshContractResult struct { + Contract ContractRevision `json:"contract"` + RenewalSet TransactionSet `json:"renewalSet"` + Cost types.Currency `json:"cost"` + } ) func callSingleRoundtripRPC(ctx context.Context, t TransportClient, rpcID types.Specifier, req, resp rhp4.Object) error { @@ -630,7 +637,7 @@ func RPCFormContract(ctx context.Context, t TransportClient, tp TxPool, signer F // RPCRenewContract renews a contract with a host. func RPCRenewContract(ctx context.Context, t TransportClient, tp TxPool, signer FormContractSigner, cs consensus.State, p rhp4.HostPrices, existing types.V2FileContract, params rhp4.RPCRenewContractParams) (RPCRenewContractResult, error) { - renewal := rhp4.NewRenewal(existing, p, params) + renewal := rhp4.RenewContract(existing, p, params) renewalTxn := types.V2Transaction{ MinerFee: tp.RecommendedFee().Mul64(1000), FileContractResolutions: []types.V2FileContractResolution{ @@ -659,7 +666,6 @@ func RPCRenewContract(ctx context.Context, t TransportClient, tp TxPool, signer if err != nil { return RPCRenewContractResult{}, fmt.Errorf("failed to fund transaction: %w", err) } - signer.SignV2Inputs(&renewalTxn, toSign) req.Basis, req.RenterParents, err = tp.V2TransactionSet(basis, renewalTxn) if err != nil { @@ -704,7 +710,7 @@ func RPCRenewContract(ctx context.Context, t TransportClient, tp TxPool, signer } // sign the renter inputs - signer.SignV2Inputs(&renewalTxn, []int{0}) + signer.SignV2Inputs(&renewalTxn, toSign) // sign the renewal renewalSigHash := cs.RenewalSigHash(renewal) renewal.RenterSignature = signer.SignHash(renewalSigHash) @@ -755,3 +761,131 @@ func RPCRenewContract(ctx context.Context, t TransportClient, tp TxPool, signer Cost: renterCost, }, nil } + +// RPCRefreshContract renews a contract with a host. +func RPCRefreshContract(ctx context.Context, t TransportClient, tp TxPool, signer FormContractSigner, cs consensus.State, p rhp4.HostPrices, existing types.V2FileContract, params rhp4.RPCRefreshContractParams) (RPCRefreshContractResult, error) { + renewal := rhp4.RefreshContract(existing, p, params) + renewalTxn := types.V2Transaction{ + MinerFee: tp.RecommendedFee().Mul64(1000), + FileContractResolutions: []types.V2FileContractResolution{ + { + Parent: types.V2FileContractElement{ + StateElement: types.StateElement{ + // the other parts of the state element are not required + // for signing the transaction. Let the host fill them + // in. + ID: types.Hash256(params.ContractID), + }, + }, + Resolution: &renewal, + }, + }, + } + + renterCost, hostCost := rhp4.RefreshCost(cs, p, renewal, renewalTxn.MinerFee) + req := rhp4.RPCRefreshContractRequest{ + Prices: p, + Refresh: params, + MinerFee: renewalTxn.MinerFee, + } + + basis, toSign, err := signer.FundV2Transaction(&renewalTxn, renterCost) + if err != nil { + return RPCRefreshContractResult{}, fmt.Errorf("failed to fund transaction: %w", err) + } + signer.SignV2Inputs(&renewalTxn, toSign) + + req.Basis, req.RenterParents, err = tp.V2TransactionSet(basis, renewalTxn) + if err != nil { + return RPCRefreshContractResult{}, fmt.Errorf("failed to get transaction set: %w", err) + } + for _, si := range renewalTxn.SiacoinInputs { + req.RenterInputs = append(req.RenterInputs, si.Parent) + } + req.RenterParents = req.RenterParents[:len(req.RenterParents)-1] // last transaction is the renewal + + sigHash := req.ChallengeSigHash(existing.RevisionNumber) + req.ChallengeSignature = signer.SignHash(sigHash) + + s := t.DialStream(ctx) + defer s.Close() + + if err := rhp4.WriteRequest(s, rhp4.RPCRefreshContractID, &req); err != nil { + return RPCRefreshContractResult{}, fmt.Errorf("failed to write request: %w", err) + } + + var hostInputsResp rhp4.RPCRefreshContractResponse + if err := rhp4.ReadResponse(s, &hostInputsResp); err != nil { + return RPCRefreshContractResult{}, fmt.Errorf("failed to read host inputs response: %w", err) + } + + // add the host inputs to the transaction + var hostInputSum types.Currency + for _, si := range hostInputsResp.HostInputs { + hostInputSum = hostInputSum.Add(si.Parent.SiacoinOutput.Value) + renewalTxn.SiacoinInputs = append(renewalTxn.SiacoinInputs, si) + } + + // verify the host added enough inputs + if n := hostInputSum.Cmp(hostCost); n < 0 { + return RPCRefreshContractResult{}, fmt.Errorf("expected host to fund %v, got %v", hostCost, hostInputSum) + } else if n > 0 { + // add change output + renewalTxn.SiacoinOutputs = append(renewalTxn.SiacoinOutputs, types.SiacoinOutput{ + Address: existing.HostOutput.Address, + Value: hostInputSum.Sub(hostCost), + }) + } + + // sign the renter inputs + signer.SignV2Inputs(&renewalTxn, []int{0}) + // sign the renewal + renewalSigHash := cs.RenewalSigHash(renewal) + renewal.RenterSignature = signer.SignHash(renewalSigHash) + + // send the renter signatures + renterPolicyResp := rhp4.RPCRefreshContractSecondResponse{ + RenterRenewalSignature: renewal.RenterSignature, + } + for _, si := range renewalTxn.SiacoinInputs[:len(req.RenterInputs)] { + renterPolicyResp.RenterSatisfiedPolicies = append(renterPolicyResp.RenterSatisfiedPolicies, si.SatisfiedPolicy) + } + if err := rhp4.WriteResponse(s, &renterPolicyResp); err != nil { + return RPCRefreshContractResult{}, fmt.Errorf("failed to write signature response: %w", err) + } + + // read the finalized transaction set + var hostTransactionSetResp rhp4.RPCRefreshContractThirdResponse + if err := rhp4.ReadResponse(s, &hostTransactionSetResp); err != nil { + return RPCRefreshContractResult{}, fmt.Errorf("failed to read final response: %w", err) + } + + if len(hostTransactionSetResp.TransactionSet) == 0 { + return RPCRefreshContractResult{}, fmt.Errorf("expected at least one host transaction") + } + hostRenewalTxn := hostTransactionSetResp.TransactionSet[len(hostTransactionSetResp.TransactionSet)-1] + if len(hostRenewalTxn.FileContractResolutions) != 1 { + return RPCRefreshContractResult{}, fmt.Errorf("expected exactly one resolution") + } + + hostRenewal, ok := hostRenewalTxn.FileContractResolutions[0].Resolution.(*types.V2FileContractRenewal) + if !ok { + return RPCRefreshContractResult{}, fmt.Errorf("expected renewal resolution") + } + + // validate the host signature + if !existing.HostPublicKey.VerifyHash(renewalSigHash, hostRenewal.HostSignature) { + return RPCRefreshContractResult{}, errors.New("invalid host signature") + } + return RPCRefreshContractResult{ + Contract: ContractRevision{ + ID: params.ContractID.V2RenewalID(), + Revision: renewal.NewContract, + }, + RenewalSet: TransactionSet{ + Basis: hostTransactionSetResp.Basis, + Transactions: hostTransactionSetResp.TransactionSet, + }, + Cost: renterCost, + }, nil +} diff --git a/rhp/v4/rpc_test.go b/rhp/v4/rpc_test.go index 4d65cc2..de64e97 100644 --- a/rhp/v4/rpc_test.go +++ b/rhp/v4/rpc_test.go @@ -6,6 +6,7 @@ import ( "io" "net" "reflect" + "strings" "sync" "testing" "time" @@ -339,15 +340,13 @@ func TestFormContractBasis(t *testing.T) { } } -func TestRenewContractPartialRollover(t *testing.T) { +func TestRPCRefresh(t *testing.T) { log := zaptest.NewLogger(t) n, genesis := testutil.V2Network() hostKey, renterKey := types.GeneratePrivateKey(), types.GeneratePrivateKey() - cm, s, w := startTestNode(t, n, genesis, log) - - // fund the wallet with two UTXOs - mineAndSync(t, cm, w.Address(), 146, w) + // fund the wallet + mineAndSync(t, cm, w.Address(), 150, w) sr := testutil.NewEphemeralSettingsReporter() sr.Update(proto4.HostSettings{ @@ -378,70 +377,119 @@ func TestRenewContractPartialRollover(t *testing.T) { t.Fatal(err) } - fundAndSign := &fundAndSign{w, renterKey} - renterAllowance, hostCollateral := types.Siacoins(100), types.Siacoins(200) - result, err := rhp4.RPCFormContract(context.Background(), transport, cm, fundAndSign, cm.TipState(), settings.Prices, hostKey.PublicKey(), settings.WalletAddress, proto4.RPCFormContractParams{ - RenterPublicKey: renterKey.PublicKey(), - RenterAddress: w.Address(), - Allowance: renterAllowance, - Collateral: hostCollateral, - ProofHeight: cm.Tip().Height + 50, - }) - if err != nil { - t.Fatal(err) - } - revision := result.Contract + t.Run("no allowance or collateral", func(t *testing.T) { + fundAndSign := &fundAndSign{w, renterKey} + renterAllowance, hostCollateral := types.Siacoins(100), types.Siacoins(200) + result, err := rhp4.RPCFormContract(context.Background(), transport, cm, fundAndSign, cm.TipState(), settings.Prices, hostKey.PublicKey(), settings.WalletAddress, proto4.RPCFormContractParams{ + RenterPublicKey: renterKey.PublicKey(), + RenterAddress: w.Address(), + Allowance: renterAllowance, + Collateral: hostCollateral, + ProofHeight: cm.Tip().Height + 50, + }) + if err != nil { + t.Fatal(err) + } + revision := result.Contract - // verify the transaction set is valid - if known, err := cm.AddV2PoolTransactions(result.FormationSet.Basis, result.FormationSet.Transactions); err != nil { - t.Fatal(err) - } else if !known { - t.Fatal("expected transaction set to be known") - } + // verify the transaction set is valid + if known, err := cm.AddV2PoolTransactions(result.FormationSet.Basis, result.FormationSet.Transactions); err != nil { + t.Fatal(err) + } else if !known { + t.Fatal("expected transaction set to be known") + } - // mine a few blocks to confirm the contract - mineAndSync(t, cm, types.VoidAddress, 10, w, c) + // mine a few blocks to confirm the contract + mineAndSync(t, cm, types.VoidAddress, 10, w, c) - // fund an account to transfer funds to the host - cs := cm.TipState() - account := proto4.Account(renterKey.PublicKey()) - accountFundAmount := types.Siacoins(25) - fundResult, err := rhp4.RPCFundAccounts(context.Background(), transport, cs, renterKey, revision, []proto4.AccountDeposit{ - {Account: account, Amount: accountFundAmount}, + // fund an account to transfer funds to the host + cs := cm.TipState() + account := proto4.Account(renterKey.PublicKey()) + accountFundAmount := types.Siacoins(25) + fundResult, err := rhp4.RPCFundAccounts(context.Background(), transport, cs, renterKey, revision, []proto4.AccountDeposit{ + {Account: account, Amount: accountFundAmount}, + }) + if err != nil { + t.Fatal(err) + } + revision.Revision = fundResult.Revision + + // renew the contract + _, err = rhp4.RPCRefreshContract(context.Background(), transport, cm, fundAndSign, cm.TipState(), settings.Prices, revision.Revision, proto4.RPCRefreshContractParams{ + ContractID: revision.ID, + Allowance: types.ZeroCurrency, + Collateral: types.ZeroCurrency, + }) + if err == nil { + t.Fatal(err) + } else if !strings.Contains(err.Error(), "allowance is zero") { + t.Fatalf("unexpected error: %v", err) + } }) - if err != nil { - t.Fatal(err) - } - revision.Revision = fundResult.Revision - // renew the contract - renewResult, err := rhp4.RPCRenewContract(context.Background(), transport, cm, fundAndSign, cm.TipState(), settings.Prices, revision.Revision, proto4.RPCRenewContractParams{ - ContractID: revision.ID, - Allowance: types.Siacoins(150), - Collateral: types.Siacoins(300), - ProofHeight: revision.Revision.ProofHeight + 10, - }) - if err != nil { - t.Fatal(err) - } + t.Run("valid refresh", func(t *testing.T) { + fundAndSign := &fundAndSign{w, renterKey} + renterAllowance, hostCollateral := types.Siacoins(100), types.Siacoins(200) + result, err := rhp4.RPCFormContract(context.Background(), transport, cm, fundAndSign, cm.TipState(), settings.Prices, hostKey.PublicKey(), settings.WalletAddress, proto4.RPCFormContractParams{ + RenterPublicKey: renterKey.PublicKey(), + RenterAddress: w.Address(), + Allowance: renterAllowance, + Collateral: hostCollateral, + ProofHeight: cm.Tip().Height + 50, + }) + if err != nil { + t.Fatal(err) + } + revision := result.Contract - // verify the transaction set is valid - if known, err := cm.AddV2PoolTransactions(renewResult.RenewalSet.Basis, renewResult.RenewalSet.Transactions); err != nil { - t.Fatal(err) - } else if !known { - t.Fatal("expected transaction set to be known") - } + // verify the transaction set is valid + if known, err := cm.AddV2PoolTransactions(result.FormationSet.Basis, result.FormationSet.Transactions); err != nil { + t.Fatal(err) + } else if !known { + t.Fatal("expected transaction set to be known") + } + + // mine a few blocks to confirm the contract + mineAndSync(t, cm, types.VoidAddress, 10, w, c) + + // fund an account to transfer funds to the host + cs := cm.TipState() + account := proto4.Account(renterKey.PublicKey()) + accountFundAmount := types.Siacoins(25) + fundResult, err := rhp4.RPCFundAccounts(context.Background(), transport, cs, renterKey, revision, []proto4.AccountDeposit{ + {Account: account, Amount: accountFundAmount}, + }) + if err != nil { + t.Fatal(err) + } + revision.Revision = fundResult.Revision + + // renew the contract + refreshResult, err := rhp4.RPCRefreshContract(context.Background(), transport, cm, fundAndSign, cm.TipState(), settings.Prices, revision.Revision, proto4.RPCRefreshContractParams{ + ContractID: revision.ID, + Allowance: types.Siacoins(10), + Collateral: types.Siacoins(20), + }) + if err != nil { + t.Fatal(err) + } + + // verify the transaction set is valid + if known, err := cm.AddV2PoolTransactions(refreshResult.RenewalSet.Basis, refreshResult.RenewalSet.Transactions); err != nil { + t.Fatal(err) + } else if !known { + t.Fatal("expected transaction set to be known") + } + }) } -func TestRenewContractFullRollover(t *testing.T) { +func TestRPCRenew(t *testing.T) { log := zaptest.NewLogger(t) n, genesis := testutil.V2Network() hostKey, renterKey := types.GeneratePrivateKey(), types.GeneratePrivateKey() - cm, s, w := startTestNode(t, n, genesis, log) - - // fund the wallet with two UTXOs - mineAndSync(t, cm, w.Address(), 146, w) + // fund the wallet + mineAndSync(t, cm, w.Address(), 150, w) sr := testutil.NewEphemeralSettingsReporter() sr.Update(proto4.HostSettings{ @@ -472,153 +520,224 @@ func TestRenewContractFullRollover(t *testing.T) { t.Fatal(err) } - fundAndSign := &fundAndSign{w, renterKey} - renterAllowance, hostCollateral := types.Siacoins(100), types.Siacoins(200) - formResult, err := rhp4.RPCFormContract(context.Background(), transport, cm, fundAndSign, cm.TipState(), settings.Prices, hostKey.PublicKey(), settings.WalletAddress, proto4.RPCFormContractParams{ - RenterPublicKey: renterKey.PublicKey(), - RenterAddress: w.Address(), - Allowance: renterAllowance, - Collateral: hostCollateral, - ProofHeight: cm.Tip().Height + 50, - }) - if err != nil { - t.Fatal(err) - } - revision := formResult.Contract - - // verify the transaction set is valid - if known, err := cm.AddV2PoolTransactions(formResult.FormationSet.Basis, formResult.FormationSet.Transactions); err != nil { - t.Fatal(err) - } else if !known { - t.Fatal("expected transaction set to be known") - } + t.Run("same duration", func(t *testing.T) { + fundAndSign := &fundAndSign{w, renterKey} + renterAllowance, hostCollateral := types.Siacoins(100), types.Siacoins(200) + result, err := rhp4.RPCFormContract(context.Background(), transport, cm, fundAndSign, cm.TipState(), settings.Prices, hostKey.PublicKey(), settings.WalletAddress, proto4.RPCFormContractParams{ + RenterPublicKey: renterKey.PublicKey(), + RenterAddress: w.Address(), + Allowance: renterAllowance, + Collateral: hostCollateral, + ProofHeight: cm.Tip().Height + 50, + }) + if err != nil { + t.Fatal(err) + } + revision := result.Contract - // mine a few blocks to confirm the contract - mineAndSync(t, cm, types.VoidAddress, 10, w, c) + // verify the transaction set is valid + if known, err := cm.AddV2PoolTransactions(result.FormationSet.Basis, result.FormationSet.Transactions); err != nil { + t.Fatal(err) + } else if !known { + t.Fatal("expected transaction set to be known") + } - // fund an account to transfer funds to the host - cs := cm.TipState() - account := proto4.Account(renterKey.PublicKey()) - accountFundAmount := types.Siacoins(25) - fundResult, err := rhp4.RPCFundAccounts(context.Background(), transport, cs, renterKey, revision, []proto4.AccountDeposit{ - {Account: account, Amount: accountFundAmount}, - }) - if err != nil { - t.Fatal(err) - } - revision.Revision = fundResult.Revision + // mine a few blocks to confirm the contract + mineAndSync(t, cm, types.VoidAddress, 10, w, c) - // renew the contract - renewResult, err := rhp4.RPCRenewContract(context.Background(), transport, cm, fundAndSign, cm.TipState(), settings.Prices, revision.Revision, proto4.RPCRenewContractParams{ - ContractID: revision.ID, - Allowance: types.Siacoins(50), - Collateral: types.Siacoins(100), - ProofHeight: revision.Revision.ProofHeight + 10, + // fund an account to transfer funds to the host + cs := cm.TipState() + account := proto4.Account(renterKey.PublicKey()) + accountFundAmount := types.Siacoins(25) + fundResult, err := rhp4.RPCFundAccounts(context.Background(), transport, cs, renterKey, revision, []proto4.AccountDeposit{ + {Account: account, Amount: accountFundAmount}, + }) + if err != nil { + t.Fatal(err) + } + revision.Revision = fundResult.Revision + + // renew the contract + _, err = rhp4.RPCRenewContract(context.Background(), transport, cm, fundAndSign, cm.TipState(), settings.Prices, revision.Revision, proto4.RPCRenewContractParams{ + ContractID: revision.ID, + Allowance: types.Siacoins(150), + Collateral: types.Siacoins(300), + ProofHeight: revision.Revision.ProofHeight, + }) + if err == nil { + t.Fatal(err) + } else if !strings.Contains(err.Error(), "renewal proof height must be greater than existing proof height") { + t.Fatalf("unexpected error: %v", err) + } }) - if err != nil { - t.Fatal(err) - } - // verify the transaction set is valid - if known, err := cm.AddV2PoolTransactions(renewResult.RenewalSet.Basis, renewResult.RenewalSet.Transactions); err != nil { - t.Fatal(err) - } else if !known { - t.Fatal("expected transaction set to be known") - } -} + t.Run("partial rollover", func(t *testing.T) { + fundAndSign := &fundAndSign{w, renterKey} + renterAllowance, hostCollateral := types.Siacoins(100), types.Siacoins(200) + result, err := rhp4.RPCFormContract(context.Background(), transport, cm, fundAndSign, cm.TipState(), settings.Prices, hostKey.PublicKey(), settings.WalletAddress, proto4.RPCFormContractParams{ + RenterPublicKey: renterKey.PublicKey(), + RenterAddress: w.Address(), + Allowance: renterAllowance, + Collateral: hostCollateral, + ProofHeight: cm.Tip().Height + 50, + }) + if err != nil { + t.Fatal(err) + } + revision := result.Contract -func TestRenewContractNoRollover(t *testing.T) { - log := zaptest.NewLogger(t) - n, genesis := testutil.V2Network() - hostKey, renterKey := types.GeneratePrivateKey(), types.GeneratePrivateKey() + // verify the transaction set is valid + if known, err := cm.AddV2PoolTransactions(result.FormationSet.Basis, result.FormationSet.Transactions); err != nil { + t.Fatal(err) + } else if !known { + t.Fatal("expected transaction set to be known") + } - cm, s, w := startTestNode(t, n, genesis, log) + // mine a few blocks to confirm the contract + mineAndSync(t, cm, types.VoidAddress, 10, w, c) - // fund the wallet with two UTXOs - mineAndSync(t, cm, w.Address(), 146, w) + // fund an account to transfer funds to the host + cs := cm.TipState() + account := proto4.Account(renterKey.PublicKey()) + accountFundAmount := types.Siacoins(25) + fundResult, err := rhp4.RPCFundAccounts(context.Background(), transport, cs, renterKey, revision, []proto4.AccountDeposit{ + {Account: account, Amount: accountFundAmount}, + }) + if err != nil { + t.Fatal(err) + } + revision.Revision = fundResult.Revision + + // renew the contract + renewResult, err := rhp4.RPCRenewContract(context.Background(), transport, cm, fundAndSign, cm.TipState(), settings.Prices, revision.Revision, proto4.RPCRenewContractParams{ + ContractID: revision.ID, + Allowance: types.Siacoins(150), + Collateral: types.Siacoins(300), + ProofHeight: revision.Revision.ProofHeight + 10, + }) + if err != nil { + t.Fatal(err) + } - sr := testutil.NewEphemeralSettingsReporter() - sr.Update(proto4.HostSettings{ - Release: "test", - AcceptingContracts: true, - WalletAddress: w.Address(), - MaxCollateral: types.Siacoins(10000), - MaxContractDuration: 1000, - MaxSectorDuration: 3 * 144, - MaxModifyActions: 100, - RemainingStorage: 100 * proto4.SectorSize, - TotalStorage: 100 * proto4.SectorSize, - Prices: proto4.HostPrices{ - ContractPrice: types.Siacoins(1).Div64(5), // 0.2 SC - StoragePrice: types.NewCurrency64(100), // 100 H / byte / block - IngressPrice: types.NewCurrency64(100), // 100 H / byte - EgressPrice: types.NewCurrency64(100), // 100 H / byte - Collateral: types.NewCurrency64(200), - }, + // verify the transaction set is valid + if known, err := cm.AddV2PoolTransactions(renewResult.RenewalSet.Basis, renewResult.RenewalSet.Transactions); err != nil { + t.Fatal(err) + } else if !known { + t.Fatal("expected transaction set to be known") + } }) - ss := testutil.NewEphemeralSectorStore() - c := testutil.NewEphemeralContractor(cm) - transport := testRenterHostPair(t, hostKey, cm, s, w, c, sr, ss, log) + t.Run("full rollover", func(t *testing.T) { + fundAndSign := &fundAndSign{w, renterKey} + renterAllowance, hostCollateral := types.Siacoins(100), types.Siacoins(200) + formResult, err := rhp4.RPCFormContract(context.Background(), transport, cm, fundAndSign, cm.TipState(), settings.Prices, hostKey.PublicKey(), settings.WalletAddress, proto4.RPCFormContractParams{ + RenterPublicKey: renterKey.PublicKey(), + RenterAddress: w.Address(), + Allowance: renterAllowance, + Collateral: hostCollateral, + ProofHeight: cm.Tip().Height + 50, + }) + if err != nil { + t.Fatal(err) + } + revision := formResult.Contract - settings, err := rhp4.RPCSettings(context.Background(), transport) - if err != nil { - t.Fatal(err) - } + // verify the transaction set is valid + if known, err := cm.AddV2PoolTransactions(formResult.FormationSet.Basis, formResult.FormationSet.Transactions); err != nil { + t.Fatal(err) + } else if !known { + t.Fatal("expected transaction set to be known") + } - fundAndSign := &fundAndSign{w, renterKey} - renterAllowance, hostCollateral := types.Siacoins(100), types.Siacoins(200) - formResult, err := rhp4.RPCFormContract(context.Background(), transport, cm, fundAndSign, cm.TipState(), settings.Prices, hostKey.PublicKey(), settings.WalletAddress, proto4.RPCFormContractParams{ - RenterPublicKey: renterKey.PublicKey(), - RenterAddress: w.Address(), - Allowance: renterAllowance, - Collateral: hostCollateral, - ProofHeight: cm.Tip().Height + 50, + // mine a few blocks to confirm the contract + mineAndSync(t, cm, types.VoidAddress, 10, w, c) + + // fund an account to transfer funds to the host + cs := cm.TipState() + account := proto4.Account(renterKey.PublicKey()) + accountFundAmount := types.Siacoins(25) + fundResult, err := rhp4.RPCFundAccounts(context.Background(), transport, cs, renterKey, revision, []proto4.AccountDeposit{ + {Account: account, Amount: accountFundAmount}, + }) + if err != nil { + t.Fatal(err) + } + revision.Revision = fundResult.Revision + + // renew the contract + renewResult, err := rhp4.RPCRenewContract(context.Background(), transport, cm, fundAndSign, cm.TipState(), settings.Prices, revision.Revision, proto4.RPCRenewContractParams{ + ContractID: revision.ID, + Allowance: types.Siacoins(50), + Collateral: types.Siacoins(100), + ProofHeight: revision.Revision.ProofHeight + 10, + }) + if err != nil { + t.Fatal(err) + } + + // verify the transaction set is valid + if known, err := cm.AddV2PoolTransactions(renewResult.RenewalSet.Basis, renewResult.RenewalSet.Transactions); err != nil { + t.Fatal(err) + } else if !known { + t.Fatal("expected transaction set to be known") + } }) - if err != nil { - t.Fatal(err) - } - revision := formResult.Contract - // verify the transaction set is valid - if known, err := cm.AddV2PoolTransactions(formResult.FormationSet.Basis, formResult.FormationSet.Transactions); err != nil { - t.Fatal(err) - } else if !known { - t.Fatal("expected transaction set to be known") - } + t.Run("no rollover", func(t *testing.T) { + fundAndSign := &fundAndSign{w, renterKey} + renterAllowance, hostCollateral := types.Siacoins(100), types.Siacoins(200) + formResult, err := rhp4.RPCFormContract(context.Background(), transport, cm, fundAndSign, cm.TipState(), settings.Prices, hostKey.PublicKey(), settings.WalletAddress, proto4.RPCFormContractParams{ + RenterPublicKey: renterKey.PublicKey(), + RenterAddress: w.Address(), + Allowance: renterAllowance, + Collateral: hostCollateral, + ProofHeight: cm.Tip().Height + 50, + }) + if err != nil { + t.Fatal(err) + } + revision := formResult.Contract - // mine a few blocks to confirm the contract - mineAndSync(t, cm, types.VoidAddress, 10, w, c) + // verify the transaction set is valid + if known, err := cm.AddV2PoolTransactions(formResult.FormationSet.Basis, formResult.FormationSet.Transactions); err != nil { + t.Fatal(err) + } else if !known { + t.Fatal("expected transaction set to be known") + } - // fund an account to transfer funds to the host - cs := cm.TipState() - account := proto4.Account(renterKey.PublicKey()) - accountFundAmount := types.Siacoins(100) - fundResult, err := rhp4.RPCFundAccounts(context.Background(), transport, cs, renterKey, revision, []proto4.AccountDeposit{ - {Account: account, Amount: accountFundAmount}, - }) - if err != nil { - t.Fatal(err) - } - revision.Revision = fundResult.Revision + // mine a few blocks to confirm the contract + mineAndSync(t, cm, types.VoidAddress, 10, w, c) - // renew the contract - renewResult, err := rhp4.RPCRenewContract(context.Background(), transport, cm, fundAndSign, cm.TipState(), settings.Prices, revision.Revision, proto4.RPCRenewContractParams{ - ContractID: revision.ID, - Allowance: types.Siacoins(150), - Collateral: types.Siacoins(300), - ProofHeight: revision.Revision.ProofHeight + 10, - }) - if err != nil { - t.Fatal(err) - } + // fund an account to transfer funds to the host + cs := cm.TipState() + account := proto4.Account(renterKey.PublicKey()) + accountFundAmount := types.Siacoins(100) + fundResult, err := rhp4.RPCFundAccounts(context.Background(), transport, cs, renterKey, revision, []proto4.AccountDeposit{ + {Account: account, Amount: accountFundAmount}, + }) + if err != nil { + t.Fatal(err) + } + revision.Revision = fundResult.Revision + + // renew the contract + renewResult, err := rhp4.RPCRenewContract(context.Background(), transport, cm, fundAndSign, cm.TipState(), settings.Prices, revision.Revision, proto4.RPCRenewContractParams{ + ContractID: revision.ID, + Allowance: types.Siacoins(150), + Collateral: types.Siacoins(300), + ProofHeight: revision.Revision.ProofHeight + 10, + }) + if err != nil { + t.Fatal(err) + } - // verify the transaction set is valid - if known, err := cm.AddV2PoolTransactions(renewResult.RenewalSet.Basis, renewResult.RenewalSet.Transactions); err != nil { - t.Fatal(err) - } else if !known { - t.Fatal("expected transaction set to be known") - } + // verify the transaction set is valid + if known, err := cm.AddV2PoolTransactions(renewResult.RenewalSet.Basis, renewResult.RenewalSet.Transactions); err != nil { + t.Fatal(err) + } else if !known { + t.Fatal("expected transaction set to be known") + } + }) } func TestAccounts(t *testing.T) { diff --git a/rhp/v4/server.go b/rhp/v4/server.go index 1e09dfc..e6756e5 100644 --- a/rhp/v4/server.go +++ b/rhp/v4/server.go @@ -657,8 +657,8 @@ func (s *Server) handleRPCFormContract(stream net.Conn) error { }) } -func (s *Server) handleRPCRenewContract(stream net.Conn) error { - var req rhp4.RPCRenewContractRequest +func (s *Server) handleRPCRefreshContract(stream net.Conn) error { + var req rhp4.RPCRefreshContractRequest if err := rhp4.ReadRequest(stream, &req); err != nil { return errorDecodingError("failed to read request: %v", err) } @@ -669,14 +669,170 @@ func (s *Server) handleRPCRenewContract(stream net.Conn) error { return fmt.Errorf("price table invalid: %w", err) } - settings := s.settings.RHP4Settings() - tip := s.chain.Tip() + // lock the existing contract + state, unlock, err := s.lockContractForRevision(req.Refresh.ContractID) + if err != nil { + return fmt.Errorf("failed to lock contract %q: %w", req.Refresh.ContractID, err) + } + defer unlock() + + // validate challenge signature + existing := state.Revision + if !req.ValidChallengeSignature(existing) { + return errorBadRequest("invalid challenge signature") + } // validate the request - if err := req.Validate(s.hostKey.PublicKey(), tip, settings.MaxCollateral, settings.MaxContractDuration); err != nil { + settings := s.settings.RHP4Settings() + if err := req.Validate(s.hostKey.PublicKey(), state.Revision.ExpirationHeight, settings.MaxCollateral); err != nil { return rhp4.NewRPCError(rhp4.ErrorCodeBadRequest, err.Error()) } + cs := s.chain.TipState() + renewal := rhp4.RefreshContract(existing, prices, req.Refresh) + renterCost, hostCost := rhp4.RefreshCost(cs, prices, renewal, req.MinerFee) + renewalTxn := types.V2Transaction{ + MinerFee: req.MinerFee, + } + + // add the renter inputs + var renterInputSum types.Currency + for _, si := range req.RenterInputs { + renewalTxn.SiacoinInputs = append(renewalTxn.SiacoinInputs, types.V2SiacoinInput{ + Parent: si, + }) + renterInputSum = renterInputSum.Add(si.SiacoinOutput.Value) + } + + if n := renterInputSum.Cmp(renterCost); n < 0 { + return errorBadRequest("expected renter to fund %v, got %v", renterInputSum, renterCost) + } else if n > 0 { + // if the renter added too much, add a change output + renewalTxn.SiacoinOutputs = append(renewalTxn.SiacoinOutputs, types.SiacoinOutput{ + Address: renewal.NewContract.RenterOutput.Address, + Value: renterInputSum.Sub(renterCost), + }) + } + + elementBasis, fce, err := s.contractor.ContractElement(req.Refresh.ContractID) + if err != nil { + return fmt.Errorf("failed to get contract element: %w", err) + } + + basis, toSign, err := s.wallet.FundV2Transaction(&renewalTxn, hostCost, true) + if errors.Is(err, wallet.ErrNotEnoughFunds) { + return rhp4.ErrHostFundError + } else if err != nil { + return fmt.Errorf("failed to fund transaction: %w", err) + } + + // update renter inputs to reflect our chain state + if basis != req.Basis { + hostInputs := renewalTxn.SiacoinInputs[len(renewalTxn.SiacoinInputs)-len(req.RenterInputs):] + renewalTxn.SiacoinInputs = renewalTxn.SiacoinInputs[:len(renewalTxn.SiacoinInputs)-len(req.RenterInputs)] + updated, err := s.chain.UpdateV2TransactionSet([]types.V2Transaction{renewalTxn}, req.Basis, basis) + if err != nil { + return errorBadRequest("failed to update renter inputs from %q to %q: %v", req.Basis, basis, err) + } + renewalTxn = updated[0] + renewalTxn.SiacoinInputs = append(renewalTxn.SiacoinInputs, hostInputs...) + } + + if elementBasis != basis { + tempTxn := types.V2Transaction{ + FileContractResolutions: []types.V2FileContractResolution{ + {Parent: fce, Resolution: &renewal}, + }, + } + updated, err := s.chain.UpdateV2TransactionSet([]types.V2Transaction{tempTxn}, elementBasis, basis) + if err != nil { + return fmt.Errorf("failed to update contract element: %w", err) + } + fce = updated[0].FileContractResolutions[0].Parent + } + renewalTxn.FileContractResolutions = []types.V2FileContractResolution{ + {Parent: fce, Resolution: &renewal}, + } + s.wallet.SignV2Inputs(&renewalTxn, toSign) + // send the host inputs to the renter + hostInputsResp := rhp4.RPCRefreshContractResponse{ + HostInputs: renewalTxn.SiacoinInputs[len(req.RenterInputs):], + } + if err := rhp4.WriteResponse(stream, &hostInputsResp); err != nil { + return fmt.Errorf("failed to send host inputs: %w", err) + } + + // read the renter's signatures + var renterSigResp rhp4.RPCRefreshContractSecondResponse + if err := rhp4.ReadResponse(stream, &renterSigResp); err != nil { + return errorDecodingError("failed to read renter signatures: %v", err) + } else if len(renterSigResp.RenterSatisfiedPolicies) != len(req.RenterInputs) { + return errorBadRequest("expected %v satisfied policies, got %v", len(req.RenterInputs), len(renterSigResp.RenterSatisfiedPolicies)) + } + + // validate the renter's signature + renewalSigHash := cs.RenewalSigHash(renewal) + if !existing.RenterPublicKey.VerifyHash(renewalSigHash, renterSigResp.RenterRenewalSignature) { + return rhp4.ErrInvalidSignature + } + renewal.RenterSignature = renterSigResp.RenterRenewalSignature + + // apply the renter's signatures + for i, policy := range renterSigResp.RenterSatisfiedPolicies { + renewalTxn.SiacoinInputs[i].SatisfiedPolicy = policy + } + renewal.HostSignature = s.hostKey.SignHash(renewalSigHash) + + // add the renter's parents to our transaction pool to ensure they are valid + // and update the proofs. + if len(req.RenterParents) > 0 { + if _, err := s.chain.AddV2PoolTransactions(req.Basis, req.RenterParents); err != nil { + return errorBadRequest("failed to add formation parents to transaction pool: %v", err) + } + } + + // get the full updated transaction set for the renewal transaction + basis, renewalSet, err := s.chain.V2TransactionSet(basis, renewalTxn) + if err != nil { + return fmt.Errorf("failed to get transaction set: %w", err) + } else if _, err = s.chain.AddV2PoolTransactions(basis, renewalSet); err != nil { + return errorBadRequest("failed to broadcast renewal set: %v", err) + } + // broadcast the transaction set + s.syncer.BroadcastV2TransactionSet(basis, renewalSet) + + // add the contract to the contractor + err = s.contractor.RenewV2Contract(TransactionSet{ + Transactions: renewalSet, + Basis: basis, + }, Usage{ + RPCRevenue: prices.ContractPrice, + StorageRevenue: renewal.NewContract.HostOutput.Value.Sub(renewal.NewContract.TotalCollateral).Sub(prices.ContractPrice), + RiskedCollateral: renewal.NewContract.TotalCollateral.Sub(renewal.NewContract.MissedHostValue), + }) + if err != nil { + return fmt.Errorf("failed to add contract: %w", err) + } + + // send the finalized transaction set to the renter + return rhp4.WriteResponse(stream, &rhp4.RPCRefreshContractThirdResponse{ + Basis: basis, + TransactionSet: renewalSet, + }) +} + +func (s *Server) handleRPCRenewContract(stream net.Conn) error { + var req rhp4.RPCRenewContractRequest + if err := rhp4.ReadRequest(stream, &req); err != nil { + return errorDecodingError("failed to read request: %v", err) + } + + // validate prices + prices := req.Prices + if err := prices.Validate(s.hostKey.PublicKey()); err != nil { + return fmt.Errorf("price table invalid: %w", err) + } + // lock the existing contract state, unlock, err := s.lockContractForRevision(req.Renewal.ContractID) if err != nil { @@ -684,6 +840,14 @@ func (s *Server) handleRPCRenewContract(stream net.Conn) error { } defer unlock() + settings := s.settings.RHP4Settings() + tip := s.chain.Tip() + + // validate the request + if err := req.Validate(s.hostKey.PublicKey(), tip, state.Revision.ProofHeight, settings.MaxCollateral, settings.MaxContractDuration); err != nil { + return rhp4.NewRPCError(rhp4.ErrorCodeBadRequest, err.Error()) + } + // validate challenge signature existing := state.Revision if !req.ValidChallengeSignature(existing) { @@ -691,7 +855,7 @@ func (s *Server) handleRPCRenewContract(stream net.Conn) error { } cs := s.chain.TipState() - renewal := rhp4.NewRenewal(existing, prices, req.Renewal) + renewal := rhp4.RenewContract(existing, prices, req.Renewal) renterCost, hostCost := rhp4.RenewalCost(cs, prices, renewal, req.MinerFee) renewalTxn := types.V2Transaction{ MinerFee: req.MinerFee, @@ -863,24 +1027,29 @@ func (s *Server) handleHostStream(stream net.Conn, log *zap.Logger) { switch id { case rhp4.RPCSettingsID: err = s.handleRPCSettings(stream) - case rhp4.RPCAccountBalanceID: - err = s.handleRPCAccountBalance(stream) + // contract case rhp4.RPCFormContractID: err = s.handleRPCFormContract(stream) - case rhp4.RPCFundAccountsID: - err = s.handleRPCFundAccounts(stream) + case rhp4.RPCRefreshContractID: + err = s.handleRPCRefreshContract(stream) + case rhp4.RPCRenewContractID: + err = s.handleRPCRenewContract(stream) case rhp4.RPCLatestRevisionID: err = s.handleRPCLatestRevision(stream) case rhp4.RPCModifySectorsID: err = s.handleRPCModifySectors(stream) + case rhp4.RPCSectorRootsID: + err = s.handleRPCSectorRoots(stream) + // account + case rhp4.RPCAccountBalanceID: + err = s.handleRPCAccountBalance(stream) + case rhp4.RPCFundAccountsID: + err = s.handleRPCFundAccounts(stream) + // sector case rhp4.RPCAppendSectorsID: err = s.handleRPCAppendSectors(stream) case rhp4.RPCReadSectorID: err = s.handleRPCReadSector(stream) - case rhp4.RPCRenewContractID: - err = s.handleRPCRenewContract(stream) - case rhp4.RPCSectorRootsID: - err = s.handleRPCSectorRoots(stream) case rhp4.RPCWriteSectorID: err = s.handleRPCWriteSector(stream) case rhp4.RPCVerifySectorID: