Skip to content

Commit

Permalink
Merge branch 'feat/track-investor'
Browse files Browse the repository at this point in the history
  • Loading branch information
miohtama committed May 13, 2017
2 parents af475b5 + 7fb43c5 commit 02d9f16
Show file tree
Hide file tree
Showing 7 changed files with 342 additions and 4 deletions.
92 changes: 89 additions & 3 deletions contracts/Crowdsale.sol
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import "./FractionalERC20.sol";
* - minimum funding goal and refund
* - various statistics during the crowdfund
* - different pricing strategies
* - different investment policies (require server side customer id, allow only whitelisted addresses)
*
*/
contract Crowdsale is Haltable {
Expand Down Expand Up @@ -61,6 +62,18 @@ contract Crowdsale is Haltable {
/* Has this crowdsale been finalized */
bool public finalized;

/* Do we need to have unique contributor id for each customer */
bool public requireCustomerId;

/**
* Do we verify that contributor has been cleared on the server side (accredited investors only).
* This method was first used in FirstBlood crowdsale to ensure all contributors have accepted terms on sale (on the web).
*/
bool public requiredSignedAddress;

/* Server side address that signed allowed contributors (Ethereum addresses) that can participate the crowdsale */
address public signerAddress;

/** How much ETH each address has invested to this crowdsale */
mapping (address => uint256) public investedAmountOf;

Expand All @@ -82,9 +95,15 @@ contract Crowdsale is Haltable {
*/
enum State{Unknown, Preparing, PreFunding, Funding, Success, Failure, Finalized, Refunding}

event Invested(address investor, uint weiAmount, uint tokenAmount);
// A new investment was made
event Invested(address investor, uint weiAmount, uint tokenAmount, uint128 customerId);

// Refund was processed for a contributor
event Refund(address investor, uint weiAmount);

// The rules were changed what kind of investments we accept
event InvestmentPolicyChanged(bool requireCustomerId, bool requiredSignedAddress, address signerAddress);

function Crowdsale(address _token, PricingStrategy _pricingStrategy, address _multisigWallet, uint _start, uint _end, uint _minimumFundingGoal) {

owner = msg.sender;
Expand Down Expand Up @@ -132,9 +151,11 @@ contract Crowdsale is Haltable {
* Crowdsale must be running for one to invest.
* We must have not pressed the emergency brake.
*
* @param receiver The Ethereum address who receives the tokens
* @param customerId (optional) UUID v4 to track the successful payments on the server side
*
*/
function invest(address receiver) inState(State.Funding) stopInEmergency payable public {
function investInternal(address receiver, uint128 customerId) inState(State.Funding) stopInEmergency private {

uint weiAmount = msg.value;
uint tokenAmount = pricingStrategy.calculatePrice(weiAmount, weiRaised, tokensSold, msg.sender, token.decimals());
Expand Down Expand Up @@ -168,7 +189,51 @@ contract Crowdsale is Haltable {
if(!multisigWallet.send(weiAmount)) throw;

// Tell us invest was success
Invested(receiver, weiAmount, tokenAmount);
Invested(receiver, weiAmount, tokenAmount, customerId);
}

/**
* Allow anonymous contributions to this crowdsale.
*/
function investWithSignedAddress(address addr, uint128 customerId, uint8 v, bytes32 r, bytes32 s) public payable {
bytes32 hash = sha256(addr);
if (ecrecover(hash, v, r, s) != signerAddress) throw;
if(customerId == 0) throw; // UUIDv4 sanity check
investInternal(addr, customerId);
}

/**
* Track who is the customer making the payment so we can send thank you email.
*/
function investWithCustomerId(address addr, uint128 customerId) public payable {
if(requiredSignedAddress) throw; // Crowdsale allows only server-side signed participants
if(customerId == 0) throw; // UUIDv4 sanity check
investInternal(addr, customerId);
}

/**
* Allow anonymous contributions to this crowdsale.
*/
function invest(address addr) public payable {
if(requireCustomerId) throw; // Crowdsale needs to track partipants for thank you email
if(requiredSignedAddress) throw; // Crowdsale allows only server-side signed participants
investInternal(addr, 0);
}

/**
* Invest to tokens, recognize the payer and clear his address.
*
*/
function buyWithSignedAddress(uint128 customerId, uint8 v, bytes32 r, bytes32 s) public payable {
investWithSignedAddress(msg.sender, customerId, v, r, s);
}

/**
* Invest to tokens, recognize the payer.
*
*/
function buyWithCustomerId(uint128 customerId) public payable {
investWithCustomerId(msg.sender, customerId);
}

/**
Expand Down Expand Up @@ -214,6 +279,27 @@ contract Crowdsale is Haltable {
}
}

/**
* Set policy do we need to have server-side customer ids for the investments.
*
*/
function setRequireCustomerId(bool value) onlyOwner {
requireCustomerId = value;
InvestmentPolicyChanged(requireCustomerId, requiredSignedAddress, signerAddress);
}

/**
* Set policy if all investors must be cleared on the server side first.
*
* This is e.g. for the accredited investor clearing.
*
*/
function setRequireSignedAddress(bool value, address _signerAddress) onlyOwner {
requiredSignedAddress = value;
signerAddress = _signerAddress;
InvestmentPolicyChanged(requireCustomerId, requiredSignedAddress, signerAddress);
}

/**
* Allow to (re)set pricing strategy.
*
Expand Down
2 changes: 1 addition & 1 deletion contracts/RelaunchedCrowdsale.sol
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ contract RelaunchedCrowdsale is MintedTokenCappedCrowdsale {
weiRaised += _weiAmount;
tokensSold += _tokenAmount;

Invested(_addr, _weiAmount, _tokenAmount);
Invested(_addr, _weiAmount, _tokenAmount, 0);
RestoredInvestment(_addr, _originalTxHash);
}

Expand Down
91 changes: 91 additions & 0 deletions ico/sign.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
"""Sign data with Ethereum private key."""
import binascii

import bitcoin
from ethereum import utils
from ethereum.utils import big_endian_to_int, sha3
from secp256k1 import PrivateKey


def get_ethereum_address_from_private_key(private_key_seed_ascii: str) -> str:
"""Generate Ethereum address from a private key.
https://github.com/ethereum/pyethsaletool/blob/master/pyethsaletool.py#L111
:param private_key: Any string as a seed
:return: 0x prefixed hex string
"""
priv = utils.sha3(private_key_seed_ascii)
pub = bitcoin.encode_pubkey(bitcoin.privtopub(priv), 'bin_electrum')
return "0x" + binascii.hexlify(sha3(pub)[12:]).decode("ascii")


def get_address_as_bytes(address: str) -> bytes:
"""Convert Ethereum address to byte data payload for signing."""
assert address.startswith("0x")
address_bytes = binascii.unhexlify(address[2:])
return address_bytes


def sign(data: bytes, private_key_seed_ascii: str, hash_function=bitcoin.bin_sha256):
"""Sign data using Ethereum private key.
:param private_key_seed_ascii: Private key seed as ASCII string
"""

msghash = hash_function(data)

priv = utils.sha3(private_key_seed_ascii)
pub = bitcoin.privtopub(priv)

# Based on ethereum/tesrt_contracts.py test_ecrecover
pk = PrivateKey(priv, raw=True)

signature = pk.ecdsa_recoverable_serialize(pk.ecdsa_sign_recoverable(msghash, raw=True))
signature = signature[0] + utils.bytearray_to_bytestr([signature[1]])

v = utils.safe_ord(signature[64]) + 27
r_bytes = signature[0:32]
r = big_endian_to_int(r_bytes)
s_bytes = signature[32:64]
s = big_endian_to_int(s_bytes)

# Make sure we use bytes data and zero padding stays
# good across different systems
r_hex = binascii.hexlify(r_bytes).decode("ascii")
s_hex = binascii.hexlify(s_bytes).decode("ascii")

# Convert to Etheruem address format
addr = utils.big_endian_to_int(utils.sha3(bitcoin.encode_pubkey(pub, 'bin')[1:])[12:])

# Return various bits about signing so it's easier to debug
return {
"signature": signature,
"v": v,
"r": r,
"s": s,
"r_bytes": r_bytes,
"s_bytes": s_bytes,
"r_hex": "0x" + r_hex,
"s_hex": "0x" + s_hex,
"address_bitcoin": addr,
"address_ethereum": get_ethereum_address_from_private_key(priv),
"public_key": pub,
"hash": msghash,
"payload": binascii.hexlify(bytes([v] + list(r_bytes)+ list(s_bytes,)))
}


def verify(msghash: bytes, signature, public_key):
"""Verify that data has been signed with Etheruem private key.
:param signature:
:return:
"""

V = utils.safe_ord(signature[64]) + 27
R = big_endian_to_int(signature[0:32])
S = big_endian_to_int(signature[32:64])

return bitcoin.ecdsa_raw_verify(msghash, (V, R, S), public_key)

67 changes: 67 additions & 0 deletions ico/tests/contracts/test_require_customer_id.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"""Customer id tracking."""
import uuid

import pytest
from ethereum.tester import TransactionFailed
from eth_utils import to_wei

from ico.tests.utils import time_travel
from ico.state import CrowdsaleState


@pytest.fixture
def crowdsale(uncapped_flatprice, uncapped_flatprice_finalizer, team_multisig):
"""Set up a crowdsale with customer id require policy."""
uncapped_flatprice.transact({"from": team_multisig}).setRequireCustomerId(True)
return uncapped_flatprice


@pytest.fixture
def token(uncapped_token):
"""Token contract we are buying."""
return uncapped_token


@pytest.fixture
def customer_id(uncapped_flatprice, uncapped_flatprice_finalizer, team_multisig) -> int:
"""Generate UUID v4 customer id as a hex string."""
customer_id = int(uuid.uuid4().hex, 16) # Customer ids are 128-bit UUID v4
return customer_id


def test_only_owner_change_change_policy(crowdsale, customer):
"""Only owner change change customerId required policy."""

with pytest.raises(TransactionFailed):
crowdsale.transact({"from": customer}).setRequireCustomerId(False)


def test_participate_with_customer_id(chain, crowdsale, customer, customer_id, token):
"""Buy tokens with a proper customer id."""

time_travel(chain, crowdsale.call().startsAt() + 1)
wei_value = to_wei(1, "ether")
assert crowdsale.call().getState() == CrowdsaleState.Funding
crowdsale.transact({"from": customer, "value": wei_value}).buyWithCustomerId(customer_id)

# We got credited
assert token.call().balanceOf(customer) > 0

# We have tracked the investor id
events = crowdsale.pastEvents("Invested").get()
assert len(events) == 1
e = events[0]
assert e["args"]["investor"] == customer
assert e["args"]["weiAmount"] == wei_value
assert e["args"]["customerId"] == customer_id


def test_participate_missing_customer_id(chain, crowdsale, customer, customer_id, token):
"""Cannot bypass customer id process."""

time_travel(chain, crowdsale.call().startsAt() + 1)
wei_value = to_wei(1, "ether")
assert crowdsale.call().getState() == CrowdsaleState.Funding

with pytest.raises(TransactionFailed):
crowdsale.transact({"from": customer, "value": wei_value}).buy()
92 changes: 92 additions & 0 deletions ico/tests/contracts/test_require_signed_address.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"""Signed address investing."""

import uuid

import pytest
from ethereum.tester import TransactionFailed
from eth_utils import to_wei

from ico.tests.utils import time_travel
from ico.state import CrowdsaleState
from ico.sign import get_ethereum_address_from_private_key
from ico.sign import get_address_as_bytes
from ico.sign import sign


@pytest.fixture
def private_key():
"""Server side private key."""
return "Toholampi summer festival 2017 has the most harcore rock bands"


@pytest.fixture
def signer_address(private_key):
"""Server side signer address."""
return get_ethereum_address_from_private_key(private_key)


@pytest.fixture
def crowdsale(uncapped_flatprice, uncapped_flatprice_finalizer, team_multisig, signer_address):
"""Set up a crowdsale with customer id require policy."""
uncapped_flatprice.transact({"from": team_multisig}).setRequireSignedAddress(True, signer_address)
return uncapped_flatprice


@pytest.fixture
def token(uncapped_token):
"""Token contract we are buying."""
return uncapped_token


@pytest.fixture
def customer_id(uncapped_flatprice, uncapped_flatprice_finalizer, team_multisig) -> int:
"""Generate UUID v4 customer id as a hex string."""
customer_id = int(uuid.uuid4().hex, 16) # Customer ids are 128-bit UUID v4
return customer_id


def test_only_owner_change_change_policy(crowdsale, customer, signer_address):
"""Only owner change change customerId required policy."""

with pytest.raises(TransactionFailed):
crowdsale.transact({"from": customer}).setRequireSignedAddress(True, signer_address)


def test_participate_with_signed_address(chain, crowdsale, customer, customer_id, token, private_key):
"""Buy tokens with a proper signed address."""

address_bytes = get_address_as_bytes(customer)
sign_data = sign(address_bytes, private_key)

time_travel(chain, crowdsale.call().startsAt() + 1)
wei_value = to_wei(1, "ether")
assert crowdsale.call().getState() == CrowdsaleState.Funding
crowdsale.transact({"from": customer, "value": wei_value}).buyWithSignedAddress(customer_id, sign_data["v"], sign_data["r_bytes"], sign_data["s_bytes"])

# We got credited
assert token.call().balanceOf(customer) > 0

# We have tracked the investor id
events = crowdsale.pastEvents("Invested").get()
assert len(events) == 1
e = events[0]
assert e["args"]["investor"] == customer
assert e["args"]["weiAmount"] == wei_value
assert e["args"]["customerId"] == customer_id


def test_participate_bad_signature(chain, crowdsale, customer, customer_id, token):
"""Investment does not happen with a bad signature.."""

address_bytes = get_address_as_bytes(customer)
sign_data = sign(address_bytes, private_key)

time_travel(chain, crowdsale.call().startsAt() + 1)
wei_value = to_wei(1, "ether")
assert crowdsale.call().getState() == CrowdsaleState.Funding

sign_data["s_bytes"] = b'ABC' # Corrupt signature data

with pytest.raises(TransactionFailed):
crowdsale.transact({"from": customer, "value": wei_value}).buyWithSignedAddress(customer_id, sign_data["v"], sign_data["r_bytes"], sign_data["s_bytes"])

0 comments on commit 02d9f16

Please sign in to comment.