Commit 398ec79f authored by Jeremy Bokobza's avatar Jeremy Bokobza Committed by GitHub

Merge pull request #98 from bokobza/master

TumbleBit integration work
parents 065a6810 91e9a8a9
......@@ -63,7 +63,7 @@ namespace Breeze.TumbleBit.Controllers
try
{
await this.tumbleBitManager.TumbleAsync(request.DestinationWalletName);
await this.tumbleBitManager.TumbleAsync(request.OriginWalletName, request.DestinationWalletName);
return this.Ok();
}
catch (Exception e)
......
using System;
using System.Collections.Generic;
using System.Text;
using NBitcoin;
using NTumbleBit;
namespace Breeze.TumbleBit.Client
{
public class ExternalServices
{
public Transaction FundTransaction(TxOut txOut, FeeRate feeRate)
{
return null;
}
public void Track(Script scriptPubkey)
{
}
public bool Broadcast(Transaction tx)
{
return true;
}
public void TrustedBroadcast(int cycleStart, TransactionType transactionType, uint correlation, TrustedBroadcastRequest broadcast)
{
}
public TransactionInformation[] GetTransactions(Script scriptPubKey, bool withProof)
{
return null;
}
public FeeRate GetFeeRate()
{
return null;
}
}
}
......@@ -8,10 +8,10 @@
void Save();
/// <summary>
/// Loads the state of the current tumbling session.
/// Loads the saved state of the tumbling execution to the file system.
/// </summary>
/// <returns></returns>
IStateMachine Load();
void LoadStateFromMemory();
/// <summary>
/// Deletes the state of the current tumbling session..
......
......@@ -17,7 +17,7 @@ namespace Breeze.TumbleBit.Client
/// <returns></returns>
Task<ClassicTumblerParameters> ConnectToTumblerAsync(Uri serverAddress);
Task TumbleAsync(string destinationWalletName);
Task TumbleAsync(string originWalletName, string destinationWalletName);
/// <summary>
/// Processes a block received from the network.
......
......@@ -40,5 +40,7 @@ namespace Breeze.TumbleBit.Client
Task<PuzzleSolver.ServerCommitment[]> SolvePuzzlesAsync(int cycleId, string channelId, PuzzleValue[] puzzles);
Task<SolutionKey[]> FulfillOfferAsync(int cycleId, string channelId, TransactionSignature clientSignature);
Task GiveEscapeKeyAsync(int cycleId, string channelId, TransactionSignature signature);
}
}
......@@ -28,7 +28,10 @@ namespace Breeze.TumbleBit.Models
public class TumbleRequest
{
[Required(ErrorMessage = "A wallet name is required.")]
[Required(ErrorMessage = "The name of the origin wallet is required.")]
public string OriginWalletName { get; set; }
[Required(ErrorMessage = "The name of the destination wallet is required.")]
public string DestinationWalletName { get; set; }
}
}
This diff is collapsed.
namespace Breeze.TumbleBit.Client
{
public enum TransactionType : int
{
TumblerEscrow,
TumblerRedeem,
/// <summary>
/// The transaction that cashout tumbler's escrow (go to client)
/// </summary>
TumblerCashout,
ClientEscrow,
ClientRedeem,
ClientOffer,
ClientEscape,
/// <summary>
/// The transaction that cashout client's escrow (go to tumbler)
/// </summary>
ClientFulfill,
ClientOfferRedeem
}
}
......@@ -23,21 +23,18 @@ namespace Breeze.TumbleBit.Client
private readonly Network network;
private TumblingState tumblingState;
private IDisposable blockReceiver;
int lastCycleStarted;
private ClassicTumblerParameters TumblerParameters { get; set; }
public TumbleBitManager(ILoggerFactory loggerFactory, IWalletManager walletManager, ConcurrentChain chain, Network network, Signals signals)
{
this.lastCycleStarted = 0;
this.walletManager = walletManager;
this.chain = chain;
this.signals = signals;
this.network = network;
this.logger = loggerFactory.CreateLogger(this.GetType().FullName);
// load the persisted tumbling state
this.tumblingState = TumblingState.LoadState();
this.tumblingState = new TumblingState(loggerFactory, this.chain, this.walletManager, this.network);
}
/// <inheritdoc />
......@@ -51,20 +48,20 @@ namespace Breeze.TumbleBit.Client
throw new Exception($"The tumbler is on network {this.TumblerParameters.Network} while the wallet is on network {this.network}.");
}
if (this.tumblingState == null)
{
this.tumblingState = new TumblingState();
}
// load the current tumbling state fromt he file system
this.tumblingState.LoadStateFromMemory();
// update and save the state
this.tumblingState.TumblerUri = serverAddress;
this.tumblingState.TumblerParameters = this.TumblerParameters;
this.tumblingState.SetClients(this.tumblerService);
this.tumblingState.Save();
return this.TumblerParameters;
}
/// <inheritdoc />
public Task TumbleAsync(string destinationWalletName)
public Task TumbleAsync(string originWalletName, string destinationWalletName)
{
// make sure the tumbler service is initialized
if (this.TumblerParameters == null || this.tumblerService == null)
......@@ -81,11 +78,21 @@ namespace Breeze.TumbleBit.Client
Wallet destinationWallet = this.walletManager.GetWallet(destinationWalletName);
if (destinationWallet == null)
{
throw new Exception($"Destination not found. Have you created a wallet with name {destinationWalletName}?");
throw new Exception($"Destination wallet not found. Have you created a wallet with name {destinationWalletName}?");
}
Wallet originWallet = this.walletManager.GetWallet(originWalletName);
if (originWallet == null)
{
throw new Exception($"Origin wallet not found. Have you created a wallet with name {originWalletName}?");
}
// update the state and save
this.tumblingState.DestinationWallet = destinationWallet;
this.tumblingState.DestinationWalletName = destinationWalletName;
this.tumblingState.OriginWallet = originWallet;
this.tumblingState.OriginWalletName = originWalletName;
this.tumblingState.Save();
// subscribe to receiving blocks
......@@ -114,29 +121,12 @@ namespace Breeze.TumbleBit.Client
/// <inheritdoc />
public void ProcessBlock(int height, Block block)
{
// TODO start the state machine
this.logger.LogDebug($"Receive block with height {height}");
this.logger.LogDebug($"Received block with height {height} during tumbling session.");
// update the block height in the tumbling state
this.tumblingState.LastBlockReceivedHeight = height;
this.tumblingState.Save();
// get the next cycle to be started
var cycle = this.TumblerParameters.CycleGenerator.GetRegistratingCycle(height);
// check if we need to start a new session starting from the registration cycle
if (this.lastCycleStarted != cycle.Start)
{
this.lastCycleStarted = cycle.Start;
this.logger.LogDebug($"new registration cycle at {cycle.Start}");
if (this.tumblingState.Sessions.SingleOrDefault(s => s.StartCycle == cycle.Start) == null)
{
this.tumblingState.CreateNewSession(cycle.Start);
this.logger.LogDebug($"new session created at {cycle.Start}");
}
}
// update the state of the tumbling session in this new block
this.tumblingState.Update();
}
......
......@@ -110,5 +110,11 @@ namespace Breeze.TumbleBit.Client
SolutionKey[] result = await this.serverAddress.AppendPathSegment($"api/v1/tumblers/0/clientchannels/{cycleId}/{channelId}/offer").PostJsonAsync(clientSignature).ReceiveJson<SolutionKey[]>();
return result;
}
/// <inheritdoc />
public async Task GiveEscapeKeyAsync(int cycleId, string channelId, TransactionSignature signature)
{
await this.serverAddress.AppendPathSegment($"api/v1/tumblers/0/clientchannels/{cycleId}/{channelId}/escape").PostJsonAsync(signature);
}
}
}
\ No newline at end of file
......@@ -4,34 +4,74 @@ using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using Microsoft.Extensions.Logging;
using NBitcoin;
using Newtonsoft.Json;
using NTumbleBit.ClassicTumbler;
using NTumbleBit.PuzzlePromise;
using NTumbleBit.PuzzleSolver;
using Stratis.Bitcoin.Wallet;
namespace Breeze.TumbleBit.Client
{
public class TumblingState : IStateMachine
public partial class TumblingState : IStateMachine
{
private const string StateFileName = "tumblebit_state.json";
private readonly ILogger logger;
private readonly ConcurrentChain chain;
private readonly IWalletManager walletManager;
private readonly CoinType coinType;
[JsonProperty("tumblerParameters")]
public ClassicTumblerParameters TumblerParameters { get; set; }
[JsonProperty("tumblerUri")]
public Uri TumblerUri { get; set; }
[JsonProperty("lastBlockReceivedHeight")]
[JsonProperty("lastBlockReceivedHeight", DefaultValueHandling = DefaultValueHandling.Ignore)]
public int LastBlockReceivedHeight { get; set; }
[JsonProperty("destinationWalletName")]
[JsonProperty("originWalletName", NullValueHandling = NullValueHandling.Ignore)]
public string OriginWalletName { get; set; }
[JsonProperty("destinationWalletName", NullValueHandling = NullValueHandling.Ignore)]
public string DestinationWalletName { get; set; }
[JsonProperty("sessions", NullValueHandling = NullValueHandling.Ignore)]
public IList<Session> Sessions { get; set; }
[JsonIgnore]
public Wallet OriginWallet { get; set; }
[JsonIgnore]
public Wallet DestinationWallet { get; set; }
[JsonIgnore]
public ITumblerService AliceClient { get; set; }
[JsonIgnore]
public ITumblerService BobClient { get; set; }
[JsonConstructor]
public TumblingState()
{
this.Sessions = new List<Session>();
}
public TumblingState(ILoggerFactory loggerFactory,
ConcurrentChain chain,
IWalletManager walletManager,
Network network)
{
this.logger = loggerFactory.CreateLogger(this.GetType().FullName);
this.chain = chain;
this.walletManager = walletManager;
this.coinType = (CoinType)network.Consensus.CoinType;
}
public void SetClients(ITumblerService tumblerService)
{
this.AliceClient = tumblerService;
this.BobClient = tumblerService;
}
/// <inheritdoc />
......@@ -41,9 +81,23 @@ namespace Breeze.TumbleBit.Client
}
/// <inheritdoc />
public IStateMachine Load()
public void LoadStateFromMemory()
{
return LoadState();
var stateFilePath = GetStateFilePath();
if (!File.Exists(stateFilePath))
{
return;
}
// load the file from the local system
var savedState = JsonConvert.DeserializeObject<TumblingState>(File.ReadAllText(stateFilePath));
this.Sessions = savedState.Sessions ?? new List<Session>();
this.OriginWalletName = savedState.OriginWalletName;
this.DestinationWalletName = savedState.DestinationWalletName;
this.LastBlockReceivedHeight = savedState.LastBlockReceivedHeight;
this.TumblerParameters = savedState.TumblerParameters;
this.TumblerUri = savedState.TumblerUri;
}
/// <inheritdoc />
......@@ -56,31 +110,51 @@ namespace Breeze.TumbleBit.Client
/// <inheritdoc />
public void Update()
{
// get a list of cycles we expect to have at this height
var cycles = this.TumblerParameters.CycleGenerator.GetCycles(this.LastBlockReceivedHeight);
// get the next cycle to be started
var cycle = this.TumblerParameters.CycleGenerator.GetRegistratingCycle(this.LastBlockReceivedHeight);
var states = cycles.SelectMany(c => this.Sessions.Where(s => s.StartCycle == c.Start)).ToList();
foreach (var state in states)
// create a new session if allowed
if (this.Sessions.Count == 0)
{
try
this.CreateNewSession(cycle.Start);
}
else
{
// TODO remove the limitation to have only 1 session
//var lastCycleStarted = this.Sessions.Max(s => s.StartCycle);
//// check if we need to start a new session starting from the registration cycle
//if (lastCycleStarted != cycle.Start)
//{
// if (this.Sessions.SingleOrDefault(s => s.StartCycle == cycle.Start) == null)
// {
// this.CreateNewSession(cycle.Start);
// }
//}
}
// get a list of cycles we expect to have at this height
var cycles = this.TumblerParameters.CycleGenerator.GetCycles(this.LastBlockReceivedHeight);
var existingSessions = cycles.SelectMany(c => this.Sessions.Where(s => s.StartCycle == c.Start)).ToList();
foreach (var existingSession in existingSessions)
{
// create a new session to be updated
var session = new Session();
if (state.NegotiationClientState != null)
if (existingSession.NegotiationClientState != null)
{
session.StartCycle = state.NegotiationClientState.CycleStart;
session.ClientChannelNegotiation = new ClientChannelNegotiation(this.TumblerParameters, state.NegotiationClientState);
session.StartCycle = existingSession.NegotiationClientState.CycleStart;
session.ClientChannelNegotiation = new ClientChannelNegotiation(this.TumblerParameters, existingSession.NegotiationClientState);
}
if (state.PromiseClientState != null)
session.PromiseClientSession = new PromiseClientSession(this.TumblerParameters.CreatePromiseParamaters(), state.PromiseClientState);
if (state.SolverClientState != null)
session.SolverClientSession = new SolverClientSession(this.TumblerParameters.CreateSolverParamaters(), state.SolverClientState);
if (existingSession.PromiseClientState != null)
session.PromiseClientSession = new PromiseClientSession(this.TumblerParameters.CreatePromiseParamaters(), existingSession.PromiseClientState);
if (existingSession.SolverClientState != null)
session.SolverClientSession = new SolverClientSession(this.TumblerParameters.CreateSolverParamaters(), existingSession.SolverClientState);
// update the session
session.Update();
this.MoveToNextPhase(session);
// replace the updated session in the list of existing sessions
int index = this.Sessions.IndexOf(state);
int index = this.Sessions.IndexOf(existingSession);
if (index != -1)
{
this.Sessions[index] = session;
......@@ -88,12 +162,11 @@ namespace Breeze.TumbleBit.Client
this.Save();
}
catch (Exception)
{
throw;
}
}
public void MoveToNextPhase(Session session)
{
this.logger.LogInformation($"Entering next phase for cycle {session.StartCycle}.");
}
public void CreateNewSession(int start)
......@@ -102,22 +175,6 @@ namespace Breeze.TumbleBit.Client
this.Save();
}
/// <summary>
/// Loads the saved state of the tumbling execution to the file system.
/// </summary>
/// <returns></returns>
public static TumblingState LoadState()
{
var stateFilePath = GetStateFilePath();
if (!File.Exists(stateFilePath))
{
return null;
}
// load the file from the local system
return JsonConvert.DeserializeObject<TumblingState>(File.ReadAllText(stateFilePath));
}
/// <summary>
/// Gets the file path of the file containing the state of the tumbling execution.
/// </summary>
......@@ -152,15 +209,13 @@ namespace Breeze.TumbleBit.Client
public SolverClientSession.State SolverClientState { get; set; }
[JsonIgnore]
public ClientChannelNegotiation ClientChannelNegotiation { get; set; }
[JsonIgnore]
public SolverClientSession SolverClientSession { get; set; }
[JsonIgnore]
public PromiseClientSession PromiseClientSession { get; set; }
public void Update()
{
}
}
}
......@@ -71,8 +71,9 @@ namespace NTumbleBit
tx.Inputs.Add(new TxIn(coin.Outpoint));
tx.Inputs[0].Sequence = 0;
tx.Outputs.Add(new TxOut(coin.Amount, redeemDestination));
var vSize = tx.GetVirtualSize() + 80;
tx.Inputs[0].ScriptSig = EscrowScriptBuilder.GenerateScriptSig(new TransactionSignature[] { null }) + Op.GetPushOp(coin.Redeem.ToBytes());
var vSize = tx.GetVirtualSize() + 80; // Size without signature + the signature size
tx.Outputs[0].Value -= feeRate.GetFee(vSize);
var redeemTransaction = new TrustedBroadcastRequest
......
using NBitcoin.RPC;
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.Linq;
......@@ -16,5 +17,33 @@ namespace NTumbleBit
Params = parameters
}, throwIfRPCError: false);
}
public static JArray ListTransactions(this RPCClient rpcClient)
{
JArray array = new JArray();
int count = 100;
int skip = 0;
int highestConfirmation = 0;
while(true)
{
var result = rpcClient.SendCommandNoThrows("listtransactions", "*", count, skip, true);
skip += count;
if(result.Error != null)
return null;
var transactions = (JArray)result.Result;
foreach(var obj in transactions)
{
array.Add(obj);
if(obj["confirmations"] != null)
{
highestConfirmation = Math.Max(highestConfirmation, (int)obj["confirmations"]);
}
}
if(transactions.Count < count || highestConfirmation >= 1400)
break;
}
return array;
}
}
}
......@@ -245,7 +245,12 @@ namespace NTumbleBit.PuzzlePromise
Transaction cashout = new Transaction();
cashout.AddInput(new TxIn(InternalState.EscrowedCoin.Outpoint, Script.Empty));
cashout.AddOutput(new TxOut(Money.Zero, cashoutDestination));
var fee = feeRate.GetFee(cashout.GetVirtualSize());
var tb = new TransactionBuilder();
tb.Extensions.Add(new EscrowBuilderExtension());
tb.AddCoins(InternalState.EscrowedCoin);
var size = tb.EstimateSize(cashout, true);
var fee = feeRate.GetFee(size);
cashout.Outputs[0].Value = InternalState.EscrowedCoin.Amount - fee;
......
......@@ -318,6 +318,15 @@ namespace NTumbleBit.PuzzleSolver
return signature;
}
public TransactionSignature SignEscape()
{
AssertState(SolverClientStates.Completed);
var dummy = new Transaction();
dummy.Inputs.Add(new TxIn(InternalState.EscrowedCoin.Outpoint));
dummy.Outputs.Add(new TxOut());
return dummy.SignInput(InternalState.EscrowKey, InternalState.EscrowedCoin, SigHash.None | SigHash.AnyoneCanPay);
}
private Transaction CreateUnsignedOfferTransaction()
{
Script offer = CreateOfferScript();
......@@ -333,15 +342,17 @@ namespace NTumbleBit.PuzzleSolver
{
var coin = CreateUnsignedOfferTransaction().Outputs.AsCoins().First().ToScriptCoin(CreateOfferScript());
var unknownOutpoints = new OutPoint(uint256.Zero, 0);
Transaction tx = new Transaction();
tx.LockTime = CreateOfferScriptParameters().Expiration;
tx.Inputs.Add(new TxIn(coin.Outpoint));
tx.Inputs.Add(new TxIn(unknownOutpoints));
tx.Inputs[0].Sequence = 0;
tx.Outputs.Add(new TxOut(coin.Amount, redeemDestination));
var vSize = tx.GetVirtualSize() + 80;
tx.Outputs[0].Value -= feeRate.GetFee(vSize);
tx.Inputs[0].ScriptSig = new Script(OpcodeType.OP_0) + Op.GetPushOp(coin.Redeem.ToBytes());
var vSize = tx.GetVirtualSize() + 80; // Size without signature + the signature size
tx.Outputs[0].Value -= feeRate.GetFee(vSize);
var redeemTransaction = new TrustedBroadcastRequest
{
Key = InternalState.RedeemKey,
......@@ -349,7 +360,7 @@ namespace NTumbleBit.PuzzleSolver
Transaction = tx
};
//Strip redeem script information so we check if TrustedBroadcastRequest can sign correctly
redeemTransaction.Transaction = redeemTransaction.ReSign(new Coin(coin.Outpoint, coin.TxOut));
redeemTransaction.Transaction = redeemTransaction.ReSign(new Coin(unknownOutpoints, coin.TxOut));
return redeemTransaction;
}
......
......@@ -20,6 +20,7 @@ namespace NTumbleBit.PuzzleSolver
WaitingRevelation,
WaitingBlindFactor,
WaitingFulfillment,
WaitingEscape,
Completed
}
public class SolverServerSession : EscrowReceiver
......@@ -289,7 +290,7 @@ namespace NTumbleBit.PuzzleSolver
public TrustedBroadcastRequest GetSignedOfferTransaction()
{
AssertState(SolverServerStates.Completed);
AssertState(SolverServerStates.WaitingEscape);
var offerTransaction = GetUnsignedOfferTransaction();
TransactionBuilder txBuilder = new TransactionBuilder();
txBuilder.Extensions.Add(new EscrowBuilderExtension());
......@@ -307,7 +308,7 @@ namespace NTumbleBit.PuzzleSolver
public SolutionKey[] GetSolutionKeys()
{
AssertState(SolverServerStates.Completed);
AssertState(SolverServerStates.WaitingEscape);
return InternalState.SolvedPuzzles.Select(s => s.SolutionKey).ToArray();
}
......@@ -330,30 +331,36 @@ namespace NTumbleBit.PuzzleSolver
Script offerScript = GetOfferScript();
var offer = GetUnsignedOfferTransaction();
PubKey clientKey = AssertValidSignature(clientSignature, offer);
TransactionBuilder builder = new TransactionBuilder();
builder.StandardTransactionPolicy.CheckFee = false;
builder.Extensions.Add(new EscrowBuilderExtension());
builder.AddCoins(InternalState.EscrowedCoin);
builder.AddKeys(InternalState.EscrowKey);
var clientKey = InternalState.GetClientEscrowPubKey();
builder.AddKnownSignature(clientKey, clientSignature);
builder.SignTransactionInPlace(offer);
if(!builder.Verify(offer))
throw new PuzzleException("invalid-signature");
throw new PuzzleException("invalid-tumbler-signature");
var offerCoin = offer.Outputs.AsCoins().First().ToScriptCoin(offerScript);
var solutions = InternalState.SolvedPuzzles.Select(s => s.SolutionKey).ToArray();
Transaction fulfill = new Transaction();
fulfill.Inputs.Add(new TxIn(offerCoin.Outpoint));
fulfill.Outputs.Add(new TxOut(offerCoin.Amount, cashout));
var size = new OfferBuilderExtension().EstimateScriptSigSize(offerCoin.Redeem);
fulfill.Outputs[0].Value -= feeRate.GetFee(size);
var fulfillScript = SolverScriptBuilder.CreateFulfillScript(NBitcoin.BuilderExtensions.BuilderExtension.DummySignature, solutions);
fulfill.Inputs[0].ScriptSig = fulfillScript + Op.GetPushOp(offerCoin.Redeem.ToBytes());
fulfill.Outputs[0].Value -= feeRate.GetFee(fulfill.GetVirtualSize());
var signature = fulfill.Inputs.AsIndexedInputs().First().Sign(InternalState.FulfillKey, offerCoin, SigHash.All);
var fulfillScript = SolverScriptBuilder.CreateFulfillScript(signature, solutions);
fulfillScript = SolverScriptBuilder.CreateFulfillScript(signature, solutions);
fulfill.Inputs[0].ScriptSig = fulfillScript + Op.GetPushOp(offerCoin.Redeem.ToBytes());
InternalState.OfferClientSignature = clientSignature;
InternalState.Status = SolverServerStates.Completed;
InternalState.Status = SolverServerStates.WaitingEscape;
return new TrustedBroadcastRequest
{
Key = InternalState.FulfillKey,
......@@ -362,6 +369,40 @@ namespace NTumbleBit.PuzzleSolver
};
}
private PubKey AssertValidSignature(TransactionSignature clientSignature, Transaction offer)
{
var signedHash = offer.Inputs.AsIndexedInputs().First().GetSignatureHash(InternalState.EscrowedCoin, clientSignature.SigHash);
var clientKey = InternalState.GetClientEscrowPubKey();
if(!clientKey.Verify(signedHash, clientSignature.Signature))
throw new PuzzleException("invalid-client-signature");
return clientKey;
}
public Transaction GetSignedEscapeTransaction(TransactionSignature clientSignature, Script cashout)
{
AssertState(SolverServerStates.WaitingEscape);
var escapeTx = GetUnsignedOfferTransaction();
escapeTx.Outputs[0].ScriptPubKey = cashout;
var clientKey = AssertValidSignature(clientSignature, escapeTx);
TransactionBuilder builder = new TransactionBuilder();
builder.StandardTransactionPolicy.CheckFee = false;
builder.Extensions.Add(new EscrowBuilderExtension());
builder.AddCoins(InternalState.EscrowedCoin);
builder.AddKnownSignature(clientKey, clientSignature);
//This add the known signature if correct SigHash
builder.SignTransactionInPlace(escapeTx, SigHash.None | SigHash.AnyoneCanPay);
//This sign SigHash.All
builder.AddKeys(InternalState.EscrowKey);
builder.SignTransactionInPlace(escapeTx);
if(!builder.Verify(escapeTx))
throw new PuzzleException("invalid-tumbler-signature");
return escapeTx;
}
private Script GetOfferScript()
{
var escrow = EscrowScriptBuilder.ExtractEscrowScriptPubKeyParameters(InternalState.EscrowedCoin.Redeem);
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment