diff --git a/docs/ADR-008-UI-Implementation-Guide.md b/docs/ADR-008-UI-Implementation-Guide.md new file mode 100644 index 0000000..9c25aa1 --- /dev/null +++ b/docs/ADR-008-UI-Implementation-Guide.md @@ -0,0 +1,224 @@ +# ADR-008 UI Implementation Guide + +This document provides guidance for front-end developers implementing UI for the ADR-008 security features. + +## 1. Commit-Reveal Investment System + +### Overview +Investments now use a two-phase commit-reveal pattern to prevent front-running attacks. Users must first commit a hash of their investment, wait for a delay period, then reveal the actual amount. + +### Flow +``` +1. User decides to invest amount X in opportunity Y +2. Frontend generates random nonce (32 bytes) +3. Frontend computes commitment = SHA256(amount || nonce || investorAddress) +4. User calls commitInvestment(opportunityID, commitment, vitaID) +5. Wait 10+ blocks (configurable: CommitRevealDelay) +6. User calls revealInvestment(commitmentID, amount, nonce) +7. If valid, investment is executed +``` + +### Contract Methods + +#### `commitInvestment(opportunityID: uint64, commitment: bytes32, vitaID: uint64) -> uint64` +- Returns: commitmentID +- Event: `CommitmentCreated(commitmentID, opportunityID, vitaID, revealDeadline)` + +#### `revealInvestment(commitmentID: uint64, amount: uint64, nonce: bytes) -> bool` +- Must be called after CommitRevealDelay blocks (default: 10) +- Must be called before CommitRevealWindow expires (default: 1000 blocks) +- Event: `CommitmentRevealed(commitmentID, amount, investmentID)` + +#### `cancelCommitment(commitmentID: uint64) -> bool` +- Can cancel pending commitments +- Event: `CommitmentCanceled(commitmentID)` + +#### `getCommitment(commitmentID: uint64) -> CommitmentInfo | null` +- Returns commitment details including status + +### Frontend Implementation Notes + +```typescript +// Generate commitment +function createCommitment(amount: bigint, investorAddress: string): { commitment: string, nonce: string } { + const nonce = crypto.randomBytes(32); + const preimage = Buffer.concat([ + Buffer.alloc(8), // amount as uint64 big-endian + nonce, + Buffer.from(investorAddress, 'hex') + ]); + // Write amount as big-endian uint64 + preimage.writeBigUInt64BE(amount, 0); + + const commitment = sha256(preimage); + return { + commitment: commitment.toString('hex'), + nonce: nonce.toString('hex') + }; +} + +// IMPORTANT: Store nonce securely until reveal! +// If user loses nonce, they cannot reveal and funds are locked until expiry +``` + +### UI Considerations +- **Nonce Storage**: Store nonce in localStorage/sessionStorage with commitmentID as key +- **Progress Indicator**: Show blocks remaining until reveal is allowed +- **Deadline Warning**: Alert user if reveal deadline is approaching +- **Retry Logic**: If reveal fails, nonce is still valid for retry + +--- + +## 2. Whale Concentration Limits + +### Overview +No single investor can hold more than 5% (configurable) of any investment opportunity's total pool. + +### How It Works +- When `invest()` is called, the contract checks: + ``` + (existingInvestment + newAmount) / (totalPool + newAmount) <= WealthConcentration + ``` +- Default limit: 500 basis points (5%) +- Enforced at investment time, not at commitment time + +### UI Considerations +- **Pre-check**: Before committing, query user's existing investment in opportunity +- **Warning**: Show warning if user is approaching concentration limit +- **Error Handling**: Handle "investment would exceed whale concentration limit" error gracefully + +### Querying Current Investment +```typescript +// Get all investments by an investor +const investments = await contract.getInvestmentsByInvestor(vitaID); +const investmentInOpp = investments.find(inv => inv.opportunityID === targetOppID); +const currentAmount = investmentInOpp?.amount || 0n; + +// Get opportunity details +const opp = await contract.getOpportunity(oppID); +const totalPool = opp.totalPool; + +// Calculate max additional investment +const maxConcentration = config.wealthConcentration; // e.g., 500 = 5% +const maxAllowed = (totalPool * BigInt(maxConcentration)) / 10000n; +const maxAdditional = maxAllowed - currentAmount; +``` + +--- + +## 3. Sybil Resistance Vesting + +### Overview +New Vita tokens have a 30-day vesting period before gaining full rights. This prevents mass creation of fake identities for manipulation. + +### Default Vesting Period +- 2,592,000 blocks (~30 days at 1 second per block) +- Committee can adjust vesting for individual tokens + +### Contract Methods + +#### `isFullyVested(tokenID: uint64) -> bool` +- Returns true if current block >= vestedUntil + +#### `getVestingInfo(tokenID: uint64) -> [vestedUntil, isFullyVested, remainingBlocks] | null` +- `vestedUntil`: Block height when vesting completes +- `isFullyVested`: Current vesting status +- `remainingBlocks`: Blocks until fully vested (0 if already vested) + +#### `setVesting(tokenID: uint64, vestedUntil: uint32) -> bool` (Committee only) +- Can accelerate vesting for verified identities +- Can extend vesting for suspicious accounts + +### UI Considerations + +#### Registration Flow +```typescript +// After Vita registration, show vesting status +const vestingInfo = await vita.getVestingInfo(tokenID); +if (!vestingInfo[1]) { // not fully vested + const remainingBlocks = vestingInfo[2]; + const estimatedTime = remainingBlocks * 1; // 1 second per block + showVestingBanner(`Your identity will be fully verified in ~${formatDuration(estimatedTime)}`); +} +``` + +#### Feature Gating +Some features may require fully vested Vita: +- **Voting**: May require full vesting to prevent vote manipulation +- **Large Investments**: Higher limits may require vesting +- **Attestation**: May need vesting to attest others + +```typescript +// Check before allowing sensitive actions +async function canPerformAction(vitaID: uint64, action: string): Promise { + const isVested = await vita.isFullyVested(vitaID); + + switch(action) { + case 'vote': + case 'large_investment': + case 'attest_others': + return isVested; + default: + return true; // Basic actions allowed during vesting + } +} +``` + +#### Vesting Progress Display +```typescript +// Calculate and display vesting progress +const vestingInfo = await vita.getVestingInfo(tokenID); +const vestedUntil = vestingInfo[0]; +const token = await vita.getTokenByID(tokenID); +const createdAt = token.createdAt; + +const totalVestingBlocks = vestedUntil - createdAt; +const elapsedBlocks = currentBlock - createdAt; +const progressPercent = Math.min(100, (elapsedBlocks / totalVestingBlocks) * 100); +``` + +--- + +## Configuration Values + +These can be queried from the contracts: + +### Collocatio Config +```typescript +const config = await collocatio.getConfig(); +// config.commitRevealDelay = 10 (blocks) +// config.commitRevealWindow = 1000 (blocks) +// config.wealthConcentration = 500 (basis points = 5%) +``` + +### Vita Vesting +```typescript +// Default vesting period: 2,592,000 blocks (~30 days) +// Query individual token vesting via getVestingInfo() +``` + +--- + +## Events to Subscribe + +### Collocatio Events +- `CommitmentCreated(commitmentID, opportunityID, vitaID, revealDeadline)` +- `CommitmentRevealed(commitmentID, amount, investmentID)` +- `CommitmentCanceled(commitmentID)` + +### Vita Events +- `VestingUpdated(tokenID, vestedUntil, updatedBy)` + +--- + +## Error Messages + +| Error | Meaning | UI Action | +|-------|---------|-----------| +| `commitment already exists` | Duplicate commitment | Show existing commitment | +| `commitment not found` | Invalid commitmentID | Check ID is correct | +| `reveal too early` | Before delay period | Show countdown | +| `reveal window expired` | Past deadline | Commitment forfeited | +| `commitment verification failed` | Wrong amount/nonce | Check stored values | +| `investment would exceed whale concentration limit` | Too much in one opp | Show max allowed | +| `vita is not fully vested` | Vesting incomplete | Show remaining time | diff --git a/pkg/core/native/collocatio.go b/pkg/core/native/collocatio.go old mode 100644 new mode 100755 index a8dad4c..1a6aa0b --- a/pkg/core/native/collocatio.go +++ b/pkg/core/native/collocatio.go @@ -13,6 +13,7 @@ import ( "github.com/tutus-one/tutus-chain/pkg/core/native/nativenames" "github.com/tutus-one/tutus-chain/pkg/core/state" "github.com/tutus-one/tutus-chain/pkg/core/storage" + "github.com/tutus-one/tutus-chain/pkg/crypto/hash" "github.com/tutus-one/tutus-chain/pkg/smartcontract" "github.com/tutus-one/tutus-chain/pkg/smartcontract/callflag" "github.com/tutus-one/tutus-chain/pkg/smartcontract/manifest" @@ -53,6 +54,10 @@ const ( collocatioPrefixEmploymentByEmployer byte = 0x51 // employerVitaID + employeeVitaID -> exists collocatioPrefixContractor byte = 0x60 // contractorVitaID -> ContractorVerification collocatioPrefixContractorByPlatform byte = 0x61 // platformHash + contractorVitaID -> exists + collocatioPrefixCommitment byte = 0x70 // commitmentID -> InvestmentCommitment + collocatioPrefixCommitmentByOpp byte = 0x71 // opportunityID + commitmentID -> exists + collocatioPrefixCommitmentByInvestor byte = 0x72 // vitaID + commitmentID -> exists + collocatioPrefixCommitmentCounter byte = 0x7F // -> next commitment ID ) // Collocatio events. @@ -73,6 +78,9 @@ const ( collocatioEmploymentRevokedEvent = "EmploymentRevoked" collocatioContractorVerifiedEvent = "ContractorVerified" collocatioContractorRevokedEvent = "ContractorRevoked" + collocatioCommitmentCreatedEvent = "CommitmentCreated" + collocatioCommitmentRevealedEvent = "CommitmentRevealed" + collocatioCommitmentCanceledEvent = "CommitmentCanceled" ) // RoleInvestmentManager is the role ID for investment management. @@ -93,6 +101,8 @@ const ( defaultMinInvestmentPeriod uint32 = 20000 defaultMinMaturityPeriod uint32 = 50000 defaultMaxViolationsBeforeBan uint8 = 3 + defaultCommitRevealDelay uint32 = 10 // Min blocks between commit and reveal + defaultCommitRevealWindow uint32 = 1000 // Max blocks to reveal after delay ) var _ interop.Contract = (*Collocatio)(nil) @@ -301,6 +311,38 @@ func newCollocatio() *Collocatio { md = NewMethodAndPrice(c.getOpportunitiesByStatus, 1<<16, callflag.ReadStates) c.AddMethod(md, desc) + // commitInvestment - Phase 1 of commit-reveal (anti-front-running) + desc = NewDescriptor("commitInvestment", smartcontract.IntegerType, + manifest.NewParameter("opportunityID", smartcontract.IntegerType), + manifest.NewParameter("commitment", smartcontract.Hash256Type)) + md = NewMethodAndPrice(c.commitInvestment, 1<<16, callflag.States|callflag.AllowNotify) + c.AddMethod(md, desc) + + // revealInvestment - Phase 2 of commit-reveal + desc = NewDescriptor("revealInvestment", smartcontract.IntegerType, + manifest.NewParameter("commitmentID", smartcontract.IntegerType), + manifest.NewParameter("amount", smartcontract.IntegerType), + manifest.NewParameter("nonce", smartcontract.ByteArrayType)) + md = NewMethodAndPrice(c.revealInvestment, 1<<17, callflag.States|callflag.AllowNotify) + c.AddMethod(md, desc) + + // cancelCommitment - Cancel a pending commitment + desc = NewDescriptor("cancelCommitment", smartcontract.BoolType, + manifest.NewParameter("commitmentID", smartcontract.IntegerType)) + md = NewMethodAndPrice(c.cancelCommitment, 1<<16, callflag.States|callflag.AllowNotify) + c.AddMethod(md, desc) + + // getCommitment - Query commitment details + desc = NewDescriptor("getCommitment", smartcontract.ArrayType, + manifest.NewParameter("commitmentID", smartcontract.IntegerType)) + md = NewMethodAndPrice(c.getCommitment, 1<<15, callflag.ReadStates) + c.AddMethod(md, desc) + + // getCommitmentCount + desc = NewDescriptor("getCommitmentCount", smartcontract.IntegerType) + md = NewMethodAndPrice(c.getCommitmentCount, 1<<15, callflag.ReadStates) + c.AddMethod(md, desc) + // ===== Events ===== eDesc := NewEventDescriptor(collocatioOpportunityCreatedEvent, manifest.NewParameter("opportunityID", smartcontract.IntegerType), @@ -384,6 +426,22 @@ func newCollocatio() *Collocatio { manifest.NewParameter("platform", smartcontract.Hash160Type)) c.AddEvent(NewEvent(eDesc)) + eDesc = NewEventDescriptor(collocatioCommitmentCreatedEvent, + manifest.NewParameter("commitmentID", smartcontract.IntegerType), + manifest.NewParameter("opportunityID", smartcontract.IntegerType), + manifest.NewParameter("investor", smartcontract.Hash160Type)) + c.AddEvent(NewEvent(eDesc)) + + eDesc = NewEventDescriptor(collocatioCommitmentRevealedEvent, + manifest.NewParameter("commitmentID", smartcontract.IntegerType), + manifest.NewParameter("investmentID", smartcontract.IntegerType), + manifest.NewParameter("amount", smartcontract.IntegerType)) + c.AddEvent(NewEvent(eDesc)) + + eDesc = NewEventDescriptor(collocatioCommitmentCanceledEvent, + manifest.NewParameter("commitmentID", smartcontract.IntegerType)) + c.AddEvent(NewEvent(eDesc)) + return c } @@ -414,6 +472,8 @@ func (c *Collocatio) Initialize(ic *interop.Context, hf *config.Hardfork, newMD MinMaturityPeriod: defaultMinMaturityPeriod, MaxViolationsBeforeBan: defaultMaxViolationsBeforeBan, ViolationCooldown: 1000000, + CommitRevealDelay: defaultCommitRevealDelay, + CommitRevealWindow: defaultCommitRevealWindow, } c.setConfigInternal(ic.DAO, &cfg) @@ -680,6 +740,33 @@ func makeCollocatioContractorByPlatformKey(platform util.Uint160, contractorVita return key } +func makeCollocatioCommitmentKey(commitmentID uint64) []byte { + key := make([]byte, 9) + key[0] = collocatioPrefixCommitment + binary.BigEndian.PutUint64(key[1:], commitmentID) + return key +} + +func makeCollocatioCommitmentByOppKey(oppID, commitmentID uint64) []byte { + key := make([]byte, 17) + key[0] = collocatioPrefixCommitmentByOpp + binary.BigEndian.PutUint64(key[1:9], oppID) + binary.BigEndian.PutUint64(key[9:], commitmentID) + return key +} + +func makeCollocatioCommitmentByInvestorKey(vitaID, commitmentID uint64) []byte { + key := make([]byte, 17) + key[0] = collocatioPrefixCommitmentByInvestor + binary.BigEndian.PutUint64(key[1:9], vitaID) + binary.BigEndian.PutUint64(key[9:], commitmentID) + return key +} + +func makeCollocatioCommitmentCounterKey() []byte { + return []byte{collocatioPrefixCommitmentCounter} +} + // ============================================================================ // Internal Storage Methods // ============================================================================ @@ -797,6 +884,43 @@ func (c *Collocatio) putEligibility(d *dao.Simple, elig *state.InvestorEligibili d.PutStorageItem(c.ID, makeCollocatioEligByOwnerKey(elig.Investor), buf) } +func (c *Collocatio) getCommitmentInternal(d *dao.Simple, commitmentID uint64) *state.InvestmentCommitment { + si := d.GetStorageItem(c.ID, makeCollocatioCommitmentKey(commitmentID)) + if si == nil { + return nil + } + commitment := new(state.InvestmentCommitment) + item, _ := stackitem.Deserialize(si) + commitment.FromStackItem(item) + return commitment +} + +func (c *Collocatio) putCommitment(d *dao.Simple, commitment *state.InvestmentCommitment) { + item, _ := commitment.ToStackItem() + data, _ := stackitem.Serialize(item) + d.PutStorageItem(c.ID, makeCollocatioCommitmentKey(commitment.ID), data) +} + +// getInvestorTotalInOpportunity returns the total amount an investor has invested in a specific opportunity. +func (c *Collocatio) getInvestorTotalInOpportunity(d *dao.Simple, vitaID, oppID uint64) uint64 { + prefix := []byte{collocatioPrefixInvestmentByInvestor} + prefix = append(prefix, make([]byte, 8)...) + binary.BigEndian.PutUint64(prefix[1:], vitaID) + + var total uint64 + d.Seek(c.ID, storage.SeekRange{Prefix: prefix}, func(k, v []byte) bool { + if len(k) >= 16 { + invID := binary.BigEndian.Uint64(k[8:16]) + inv := c.getInvestmentInternal(d, invID) + if inv != nil && inv.OpportunityID == oppID && inv.Status == state.InvestmentActive { + total += inv.Amount + } + } + return true + }) + return total +} + // ============================================================================ // Contract Methods // ============================================================================ @@ -1016,6 +1140,21 @@ func (c *Collocatio) invest(ic *interop.Context, args []stackitem.Item) stackite // Calculate fee cfg := c.getConfigInternal(ic.DAO) + + // Whale concentration check - prevent any single investor from holding too much of the pool + if cfg.WealthConcentration > 0 { + existingInvestment := c.getInvestorTotalInOpportunity(ic.DAO, vitaID, oppID) + newTotal := existingInvestment + amount + // Calculate what percentage of the pool this investor would hold + // (newTotal / (opp.TotalPool + amount)) * 10000 > WealthConcentration + futurePool := opp.TotalPool + amount + if futurePool > 0 { + concentration := (newTotal * 10000) / futurePool + if concentration > cfg.WealthConcentration { + panic("investment would exceed whale concentration limit") + } + } + } fee := (amount * cfg.InvestmentFee) / 10000 netAmount := amount - fee @@ -2176,3 +2315,300 @@ func contractorToStackItem(cv *state.ContractorVerification) stackitem.Item { stackitem.NewByteArray(cv.VerifiedBy.BytesBE()), }) } + +func commitmentToStackItem(c *state.InvestmentCommitment) stackitem.Item { + return stackitem.NewArray([]stackitem.Item{ + stackitem.NewBigInteger(new(big.Int).SetUint64(c.ID)), + stackitem.NewBigInteger(new(big.Int).SetUint64(c.OpportunityID)), + stackitem.NewBigInteger(new(big.Int).SetUint64(c.VitaID)), + stackitem.NewByteArray(c.Investor.BytesBE()), + stackitem.NewByteArray(c.Commitment.BytesBE()), + stackitem.NewBigInteger(new(big.Int).SetUint64(uint64(c.Status))), + stackitem.NewBigInteger(new(big.Int).SetUint64(uint64(c.CommittedAt))), + stackitem.NewBigInteger(new(big.Int).SetUint64(uint64(c.RevealDeadline))), + stackitem.NewBigInteger(new(big.Int).SetUint64(c.RevealedAmount)), + stackitem.NewBigInteger(new(big.Int).SetUint64(c.InvestmentID)), + }) +} + +// ============================================================================ +// Commit-Reveal System (Anti-Front-Running) +// ============================================================================ + +// commitInvestment creates a commitment to invest without revealing the amount. +// The commitment is hash(amount || nonce || investor). +func (col *Collocatio) commitInvestment(ic *interop.Context, args []stackitem.Item) stackitem.Item { + oppID := toUint64(args[0]) + commitmentHashBytes, err := args[1].TryBytes() + if err != nil { + panic(err) + } + commitmentHash, err := util.Uint256DecodeBytesBE(commitmentHashBytes) + if err != nil { + panic("invalid commitment hash") + } + + caller := ic.VM.GetCallingScriptHash() + + // Validate caller has Vita + vita, err := col.Vita.GetTokenByOwner(ic.DAO, caller) + if err != nil { + panic("caller must have Vita token") + } + vitaID := vita.TokenID + + // Get opportunity + opp := col.getOpportunityInternal(ic.DAO, oppID) + if opp == nil { + panic("opportunity not found") + } + + if opp.Status != state.OpportunityActive { + panic("opportunity is not active") + } + if ic.Block.Index > opp.InvestmentDeadline { + panic("investment deadline has passed") + } + + // Check eligibility + if !col.isEligibleInternal(ic.DAO, caller, opp.Type) { + panic("investor not eligible for this opportunity type") + } + + cfg := col.getConfigInternal(ic.DAO) + + // Create commitment + commitmentID := col.incrementCounter(ic.DAO, makeCollocatioCommitmentCounterKey()) + currentBlock := ic.Block.Index + + commitment := &state.InvestmentCommitment{ + ID: commitmentID, + OpportunityID: oppID, + VitaID: vitaID, + Investor: caller, + Commitment: commitmentHash, + Status: state.CommitmentPending, + CommittedAt: currentBlock, + RevealDeadline: currentBlock + cfg.CommitRevealDelay + cfg.CommitRevealWindow, + RevealedAmount: 0, + InvestmentID: 0, + } + + col.putCommitment(ic.DAO, commitment) + + // Store indexes + ic.DAO.PutStorageItem(col.ID, makeCollocatioCommitmentByOppKey(oppID, commitmentID), []byte{1}) + ic.DAO.PutStorageItem(col.ID, makeCollocatioCommitmentByInvestorKey(vitaID, commitmentID), []byte{1}) + + // Emit event + ic.AddNotification(col.Hash, collocatioCommitmentCreatedEvent, stackitem.NewArray([]stackitem.Item{ + stackitem.NewBigInteger(new(big.Int).SetUint64(commitmentID)), + stackitem.NewBigInteger(new(big.Int).SetUint64(oppID)), + stackitem.NewByteArray(caller.BytesBE()), + })) + + return stackitem.NewBigInteger(new(big.Int).SetUint64(commitmentID)) +} + +// revealInvestment reveals the investment amount and executes the investment. +// The reveal must happen after CommitRevealDelay blocks but before RevealDeadline. +func (col *Collocatio) revealInvestment(ic *interop.Context, args []stackitem.Item) stackitem.Item { + commitmentID := toUint64(args[0]) + amount := toUint64(args[1]) + nonce, err := args[2].TryBytes() + if err != nil { + panic("invalid nonce") + } + + caller := ic.VM.GetCallingScriptHash() + + // Get commitment + commitment := col.getCommitmentInternal(ic.DAO, commitmentID) + if commitment == nil { + panic("commitment not found") + } + + if commitment.Investor != caller { + panic("only commitment owner can reveal") + } + + if commitment.Status != state.CommitmentPending { + panic("commitment already processed") + } + + cfg := col.getConfigInternal(ic.DAO) + + // Check timing + currentBlock := ic.Block.Index + revealStart := commitment.CommittedAt + cfg.CommitRevealDelay + if currentBlock < revealStart { + panic("reveal period not started yet") + } + if currentBlock > commitment.RevealDeadline { + panic("reveal deadline passed") + } + + // Verify commitment: hash(amount || nonce || investor) + preimage := make([]byte, 8+len(nonce)+util.Uint160Size) + binary.BigEndian.PutUint64(preimage[:8], amount) + copy(preimage[8:8+len(nonce)], nonce) + copy(preimage[8+len(nonce):], caller.BytesBE()) + + // Hash the preimage using SHA256 + computedHash := hash.Sha256(preimage) + if computedHash != commitment.Commitment { + panic("commitment verification failed") + } + + // Get opportunity + opp := col.getOpportunityInternal(ic.DAO, commitment.OpportunityID) + if opp == nil { + panic("opportunity not found") + } + + if opp.Status != state.OpportunityActive { + panic("opportunity is not active") + } + if currentBlock > opp.InvestmentDeadline { + panic("investment deadline has passed") + } + + // Validate amount + if amount < opp.MinInvestment { + panic("investment below minimum") + } + if amount > opp.MaxInvestment { + panic("investment exceeds maximum") + } + + if opp.MaxParticipants > 0 && opp.CurrentParticipants >= opp.MaxParticipants { + panic("maximum participants reached") + } + + // Whale concentration check + if cfg.WealthConcentration > 0 { + existingInvestment := col.getInvestorTotalInOpportunity(ic.DAO, commitment.VitaID, commitment.OpportunityID) + newTotal := existingInvestment + amount + futurePool := opp.TotalPool + amount + if futurePool > 0 { + concentration := (newTotal * 10000) / futurePool + if concentration > cfg.WealthConcentration { + panic("investment would exceed whale concentration limit") + } + } + } + + // Calculate fee + fee := (amount * cfg.InvestmentFee) / 10000 + netAmount := amount - fee + + // Transfer VTS from investor + if err := col.VTS.transferUnrestricted(ic, caller, col.Hash, new(big.Int).SetUint64(amount), nil); err != nil { + panic("failed to transfer investment amount") + } + + // Send fee to Treasury + if fee > 0 { + if err := col.VTS.transferUnrestricted(ic, col.Hash, nativehashes.Treasury, new(big.Int).SetUint64(fee), nil); err != nil { + panic("failed to transfer fee to treasury") + } + } + + // Create investment record + invID := col.incrementCounter(ic.DAO, makeCollocatioInvCounterKey()) + + inv := &state.Investment{ + ID: invID, + OpportunityID: commitment.OpportunityID, + VitaID: commitment.VitaID, + Investor: caller, + Amount: netAmount, + Status: state.InvestmentActive, + ReturnAmount: 0, + CreatedAt: currentBlock, + UpdatedAt: currentBlock, + } + + col.putInvestment(ic.DAO, inv) + + // Store indexes + ic.DAO.PutStorageItem(col.ID, makeCollocatioInvByOppKey(commitment.OpportunityID, invID), []byte{1}) + ic.DAO.PutStorageItem(col.ID, makeCollocatioInvByInvestorKey(commitment.VitaID, invID), []byte{1}) + + // Update opportunity + opp.CurrentParticipants++ + opp.TotalPool += netAmount + opp.UpdatedAt = currentBlock + col.putOpportunity(ic.DAO, opp) + + // Update commitment + commitment.Status = state.CommitmentRevealed + commitment.RevealedAmount = amount + commitment.InvestmentID = invID + col.putCommitment(ic.DAO, commitment) + + // Update eligibility + col.updateEligibilityOnInvest(ic.DAO, caller, commitment.VitaID, netAmount, currentBlock) + + // Emit events + ic.AddNotification(col.Hash, collocatioCommitmentRevealedEvent, stackitem.NewArray([]stackitem.Item{ + stackitem.NewBigInteger(new(big.Int).SetUint64(commitmentID)), + stackitem.NewBigInteger(new(big.Int).SetUint64(invID)), + stackitem.NewBigInteger(new(big.Int).SetUint64(amount)), + })) + + ic.AddNotification(col.Hash, collocatioInvestmentMadeEvent, stackitem.NewArray([]stackitem.Item{ + stackitem.NewBigInteger(new(big.Int).SetUint64(invID)), + stackitem.NewBigInteger(new(big.Int).SetUint64(commitment.OpportunityID)), + stackitem.NewByteArray(caller.BytesBE()), + stackitem.NewBigInteger(new(big.Int).SetUint64(netAmount)), + })) + + return stackitem.NewBigInteger(new(big.Int).SetUint64(invID)) +} + +// cancelCommitment cancels a pending commitment. +func (col *Collocatio) cancelCommitment(ic *interop.Context, args []stackitem.Item) stackitem.Item { + commitmentID := toUint64(args[0]) + caller := ic.VM.GetCallingScriptHash() + + commitment := col.getCommitmentInternal(ic.DAO, commitmentID) + if commitment == nil { + panic("commitment not found") + } + + if commitment.Investor != caller { + panic("only commitment owner can cancel") + } + + if commitment.Status != state.CommitmentPending { + panic("commitment already processed") + } + + // Update commitment status + commitment.Status = state.CommitmentCanceled + col.putCommitment(ic.DAO, commitment) + + // Emit event + ic.AddNotification(col.Hash, collocatioCommitmentCanceledEvent, stackitem.NewArray([]stackitem.Item{ + stackitem.NewBigInteger(new(big.Int).SetUint64(commitmentID)), + })) + + return stackitem.NewBool(true) +} + +// getCommitment returns commitment details. +func (col *Collocatio) getCommitment(ic *interop.Context, args []stackitem.Item) stackitem.Item { + commitmentID := toUint64(args[0]) + commitment := col.getCommitmentInternal(ic.DAO, commitmentID) + if commitment == nil { + return stackitem.Null{} + } + return commitmentToStackItem(commitment) +} + +// getCommitmentCount returns the total number of commitments. +func (col *Collocatio) getCommitmentCount(ic *interop.Context, _ []stackitem.Item) stackitem.Item { + count := col.getCounter(ic.DAO, makeCollocatioCommitmentCounterKey()) + return stackitem.NewBigInteger(new(big.Int).SetUint64(count)) +} diff --git a/pkg/core/native/vita.go b/pkg/core/native/vita.go index cc116f5..c76de51 100755 --- a/pkg/core/native/vita.go +++ b/pkg/core/native/vita.go @@ -63,8 +63,13 @@ const ( RecoveryApprovalEvent = "RecoveryApproval" RecoveryExecutedEvent = "RecoveryExecuted" RecoveryCancelledEvent = "RecoveryCancelled" + VestingUpdatedEvent = "VestingUpdated" ) +// Default vesting period in blocks (approximately 30 days at 1 second per block). +// New Vita tokens must vest before having full rights (Sybil resistance). +const defaultVestingPeriod uint32 = 2592000 // 30 days * 24 hours * 60 minutes * 60 seconds + // Various errors. var ( ErrTokenAlreadyExists = errors.New("token already exists for this owner") @@ -77,6 +82,7 @@ var ( ErrInvalidPersonHash = errors.New("invalid person hash") ErrNotCommittee = errors.New("invalid committee signature") ErrVitaInvalidWitness = errors.New("invalid witness") + ErrVitaNotFullyVested = errors.New("vita is not fully vested") ErrAttributeNotFound = errors.New("attribute not found") ErrAttributeRevoked = errors.New("attribute is already revoked") ErrAttributeExpired = errors.New("attribute has expired") @@ -321,6 +327,25 @@ func newVita() *Vita { md = NewMethodAndPrice(v.requirePermission, 1<<15, callflag.ReadStates) v.AddMethod(md, desc) + // SetVesting method (committee only) + desc = NewDescriptor("setVesting", smartcontract.BoolType, + manifest.NewParameter("tokenId", smartcontract.IntegerType), + manifest.NewParameter("vestedUntil", smartcontract.IntegerType)) + md = NewMethodAndPrice(v.setVesting, 1<<16, callflag.States|callflag.AllowNotify) + v.AddMethod(md, desc) + + // IsFullyVested method + desc = NewDescriptor("isFullyVested", smartcontract.BoolType, + manifest.NewParameter("tokenId", smartcontract.IntegerType)) + md = NewMethodAndPrice(v.isFullyVested, 1<<15, callflag.ReadStates) + v.AddMethod(md, desc) + + // GetVestingInfo method + desc = NewDescriptor("getVestingInfo", smartcontract.ArrayType, + manifest.NewParameter("tokenId", smartcontract.IntegerType)) + md = NewMethodAndPrice(v.getVestingInfo, 1<<15, callflag.ReadStates) + v.AddMethod(md, desc) + // Events eDesc := NewEventDescriptor(VitaCreatedEvent, manifest.NewParameter("tokenId", smartcontract.ByteArrayType), @@ -396,6 +421,12 @@ func newVita() *Vita { manifest.NewParameter("cancelledBy", smartcontract.Hash160Type)) v.AddEvent(NewEvent(eDesc)) + eDesc = NewEventDescriptor(VestingUpdatedEvent, + manifest.NewParameter("tokenId", smartcontract.IntegerType), + manifest.NewParameter("vestedUntil", smartcontract.IntegerType), + manifest.NewParameter("updatedBy", smartcontract.Hash160Type)) + v.AddEvent(NewEvent(eDesc)) + return v } @@ -634,7 +665,10 @@ func (v *Vita) register(ic *interop.Context, args []stackitem.Item) stackitem.It // Get next token ID tokenID := v.getAndIncrementTokenCounter(ic.DAO) - // Create token + // Create token with vesting period for Sybil resistance + // New tokens must vest before having full rights + vestedUntil := ic.Block.Index + defaultVestingPeriod + token := &state.Vita{ TokenID: tokenID, Owner: owner, @@ -645,6 +679,7 @@ func (v *Vita) register(ic *interop.Context, args []stackitem.Item) stackitem.It Status: state.TokenStatusActive, StatusReason: "", RecoveryHash: recoveryHash, + VestedUntil: vestedUntil, } // Store token @@ -2104,3 +2139,124 @@ func (v *Vita) HasCoreRole(ic *interop.Context, tokenID uint64, role CoreRole) b roles := v.getCoreRoles(ic, token) return roles&(1<= vestedUntil. +func (v *Vita) isFullyVested(ic *interop.Context, args []stackitem.Item) stackitem.Item { + tokenID := toBigInt(args[0]).Uint64() + + token, err := v.getTokenByIDInternal(ic.DAO, tokenID) + if err != nil { + panic(err) + } + if token == nil { + panic(ErrTokenNotFound) + } + + // Token is fully vested if current block >= vestedUntil + // VestedUntil of 0 means immediately vested (legacy tokens) + isVested := token.VestedUntil == 0 || ic.Block.Index >= token.VestedUntil + return stackitem.NewBool(isVested) +} + +// getVestingInfo returns vesting information for a Vita token. +// Returns [vestedUntil, isFullyVested, remainingBlocks] or null if token not found. +func (v *Vita) getVestingInfo(ic *interop.Context, args []stackitem.Item) stackitem.Item { + tokenID := toBigInt(args[0]).Uint64() + + token, err := v.getTokenByIDInternal(ic.DAO, tokenID) + if err != nil { + panic(err) + } + if token == nil { + return stackitem.Null{} + } + + isVested := token.VestedUntil == 0 || ic.Block.Index >= token.VestedUntil + + var remainingBlocks uint32 + if !isVested && token.VestedUntil > ic.Block.Index { + remainingBlocks = token.VestedUntil - ic.Block.Index + } + + return stackitem.NewArray([]stackitem.Item{ + stackitem.NewBigInteger(big.NewInt(int64(token.VestedUntil))), + stackitem.NewBool(isVested), + stackitem.NewBigInteger(big.NewInt(int64(remainingBlocks))), + }) +} + +// Public methods for cross-native vesting access + +// IsFullyVestedInternal checks if a Vita token has completed its vesting period. +// For cross-contract use by other native contracts like Collocatio and Eligere. +func (v *Vita) IsFullyVestedInternal(d *dao.Simple, tokenID uint64, currentBlock uint32) bool { + token, err := v.getTokenByIDInternal(d, tokenID) + if err != nil || token == nil { + return false + } + // VestedUntil of 0 means immediately vested (legacy tokens) + return token.VestedUntil == 0 || currentBlock >= token.VestedUntil +} + +// GetVestedUntil returns the vesting block height for a Vita token. +// Returns 0 if token not found. +func (v *Vita) GetVestedUntil(d *dao.Simple, tokenID uint64) uint32 { + token, err := v.getTokenByIDInternal(d, tokenID) + if err != nil || token == nil { + return 0 + } + return token.VestedUntil +} diff --git a/pkg/core/state/collocatio.go b/pkg/core/state/collocatio.go index 4ada515..7864099 100644 --- a/pkg/core/state/collocatio.go +++ b/pkg/core/state/collocatio.go @@ -1087,6 +1087,10 @@ type CollocatioConfig struct { // Violation thresholds MaxViolationsBeforeBan uint8 // Violations before permanent ban (default: 3) ViolationCooldown uint32 // Blocks before violation expires (default: 1000000) + + // Commit-reveal timing (anti-front-running) + CommitRevealDelay uint32 // Min blocks between commit and reveal (default: 10) + CommitRevealWindow uint32 // Max blocks to reveal after delay (default: 1000) } // DecodeBinary implements the io.Serializable interface. @@ -1105,6 +1109,8 @@ func (c *CollocatioConfig) DecodeBinary(br *io.BinReader) { c.MinMaturityPeriod = br.ReadU32LE() c.MaxViolationsBeforeBan = br.ReadB() c.ViolationCooldown = br.ReadU32LE() + c.CommitRevealDelay = br.ReadU32LE() + c.CommitRevealWindow = br.ReadU32LE() } // EncodeBinary implements the io.Serializable interface. @@ -1123,6 +1129,8 @@ func (c *CollocatioConfig) EncodeBinary(bw *io.BinWriter) { bw.WriteU32LE(c.MinMaturityPeriod) bw.WriteB(c.MaxViolationsBeforeBan) bw.WriteU32LE(c.ViolationCooldown) + bw.WriteU32LE(c.CommitRevealDelay) + bw.WriteU32LE(c.CommitRevealWindow) } // ToStackItem implements stackitem.Convertible interface. @@ -1142,6 +1150,8 @@ func (c *CollocatioConfig) ToStackItem() (stackitem.Item, error) { stackitem.NewBigInteger(big.NewInt(int64(c.MinMaturityPeriod))), stackitem.NewBigInteger(big.NewInt(int64(c.MaxViolationsBeforeBan))), stackitem.NewBigInteger(big.NewInt(int64(c.ViolationCooldown))), + stackitem.NewBigInteger(big.NewInt(int64(c.CommitRevealDelay))), + stackitem.NewBigInteger(big.NewInt(int64(c.CommitRevealWindow))), }), nil } @@ -1151,8 +1161,8 @@ func (c *CollocatioConfig) FromStackItem(item stackitem.Item) error { if !ok { return errors.New("not a struct") } - if len(items) != 14 { - return fmt.Errorf("wrong number of elements: expected 14, got %d", len(items)) + if len(items) != 16 { + return fmt.Errorf("wrong number of elements: expected 16, got %d", len(items)) } minPIO, err := items[0].TryInteger() @@ -1239,6 +1249,18 @@ func (c *CollocatioConfig) FromStackItem(item stackitem.Item) error { } c.ViolationCooldown = uint32(cooldown.Uint64()) + commitDelay, err := items[14].TryInteger() + if err != nil { + return fmt.Errorf("invalid commitRevealDelay: %w", err) + } + c.CommitRevealDelay = uint32(commitDelay.Uint64()) + + commitWindow, err := items[15].TryInteger() + if err != nil { + return fmt.Errorf("invalid commitRevealWindow: %w", err) + } + c.CommitRevealWindow = uint32(commitWindow.Uint64()) + return nil } @@ -1259,5 +1281,156 @@ func DefaultCollocatioConfig() CollocatioConfig { MinMaturityPeriod: 50000, // ~50000 blocks MaxViolationsBeforeBan: 3, ViolationCooldown: 1000000, // ~1M blocks + CommitRevealDelay: 10, // ~10 blocks minimum between commit and reveal + CommitRevealWindow: 1000, // ~1000 blocks to reveal after delay } } + +// CommitmentStatus represents the status of an investment commitment. +type CommitmentStatus uint8 + +// Commitment statuses. +const ( + CommitmentPending CommitmentStatus = 0 // Awaiting reveal + CommitmentRevealed CommitmentStatus = 1 // Successfully revealed and invested + CommitmentExpired CommitmentStatus = 2 // Expired without reveal + CommitmentCanceled CommitmentStatus = 3 // Canceled by investor +) + +// InvestmentCommitment represents a commit-reveal commitment for investment. +// This prevents front-running attacks by hiding the investment amount until reveal. +type InvestmentCommitment struct { + ID uint64 // Unique commitment ID + OpportunityID uint64 // Opportunity being invested in + VitaID uint64 // Investor's Vita ID + Investor util.Uint160 // Investor's address + Commitment util.Uint256 // hash(amount || nonce || investor) + Status CommitmentStatus // Current status + CommittedAt uint32 // Block height when committed + RevealDeadline uint32 // Block height by which reveal must occur + RevealedAmount uint64 // Amount revealed (0 until revealed) + InvestmentID uint64 // Resulting investment ID (0 until revealed) +} + +// DecodeBinary implements the io.Serializable interface. +func (c *InvestmentCommitment) DecodeBinary(br *io.BinReader) { + c.ID = br.ReadU64LE() + c.OpportunityID = br.ReadU64LE() + c.VitaID = br.ReadU64LE() + br.ReadBytes(c.Investor[:]) + br.ReadBytes(c.Commitment[:]) + c.Status = CommitmentStatus(br.ReadB()) + c.CommittedAt = br.ReadU32LE() + c.RevealDeadline = br.ReadU32LE() + c.RevealedAmount = br.ReadU64LE() + c.InvestmentID = br.ReadU64LE() +} + +// EncodeBinary implements the io.Serializable interface. +func (c *InvestmentCommitment) EncodeBinary(bw *io.BinWriter) { + bw.WriteU64LE(c.ID) + bw.WriteU64LE(c.OpportunityID) + bw.WriteU64LE(c.VitaID) + bw.WriteBytes(c.Investor[:]) + bw.WriteBytes(c.Commitment[:]) + bw.WriteB(byte(c.Status)) + bw.WriteU32LE(c.CommittedAt) + bw.WriteU32LE(c.RevealDeadline) + bw.WriteU64LE(c.RevealedAmount) + bw.WriteU64LE(c.InvestmentID) +} + +// ToStackItem implements stackitem.Convertible interface. +func (c *InvestmentCommitment) ToStackItem() (stackitem.Item, error) { + return stackitem.NewStruct([]stackitem.Item{ + stackitem.NewBigInteger(big.NewInt(int64(c.ID))), + stackitem.NewBigInteger(big.NewInt(int64(c.OpportunityID))), + stackitem.NewBigInteger(big.NewInt(int64(c.VitaID))), + stackitem.NewByteArray(c.Investor.BytesBE()), + stackitem.NewByteArray(c.Commitment.BytesBE()), + stackitem.NewBigInteger(big.NewInt(int64(c.Status))), + stackitem.NewBigInteger(big.NewInt(int64(c.CommittedAt))), + stackitem.NewBigInteger(big.NewInt(int64(c.RevealDeadline))), + stackitem.NewBigInteger(big.NewInt(int64(c.RevealedAmount))), + stackitem.NewBigInteger(big.NewInt(int64(c.InvestmentID))), + }), nil +} + +// FromStackItem implements stackitem.Convertible interface. +func (c *InvestmentCommitment) FromStackItem(item stackitem.Item) error { + items, ok := item.Value().([]stackitem.Item) + if !ok { + return errors.New("not a struct") + } + if len(items) != 10 { + return fmt.Errorf("wrong number of elements: expected 10, got %d", len(items)) + } + + id, err := items[0].TryInteger() + if err != nil { + return fmt.Errorf("invalid id: %w", err) + } + c.ID = id.Uint64() + + oppID, err := items[1].TryInteger() + if err != nil { + return fmt.Errorf("invalid opportunityID: %w", err) + } + c.OpportunityID = oppID.Uint64() + + vitaID, err := items[2].TryInteger() + if err != nil { + return fmt.Errorf("invalid vitaID: %w", err) + } + c.VitaID = vitaID.Uint64() + + investor, err := items[3].TryBytes() + if err != nil { + return fmt.Errorf("invalid investor: %w", err) + } + c.Investor, err = util.Uint160DecodeBytesBE(investor) + if err != nil { + return fmt.Errorf("invalid investor address: %w", err) + } + + commitment, err := items[4].TryBytes() + if err != nil { + return fmt.Errorf("invalid commitment: %w", err) + } + c.Commitment, err = util.Uint256DecodeBytesBE(commitment) + if err != nil { + return fmt.Errorf("invalid commitment hash: %w", err) + } + + status, err := items[5].TryInteger() + if err != nil { + return fmt.Errorf("invalid status: %w", err) + } + c.Status = CommitmentStatus(status.Uint64()) + + committedAt, err := items[6].TryInteger() + if err != nil { + return fmt.Errorf("invalid committedAt: %w", err) + } + c.CommittedAt = uint32(committedAt.Uint64()) + + revealDeadline, err := items[7].TryInteger() + if err != nil { + return fmt.Errorf("invalid revealDeadline: %w", err) + } + c.RevealDeadline = uint32(revealDeadline.Uint64()) + + revealedAmount, err := items[8].TryInteger() + if err != nil { + return fmt.Errorf("invalid revealedAmount: %w", err) + } + c.RevealedAmount = revealedAmount.Uint64() + + investmentID, err := items[9].TryInteger() + if err != nil { + return fmt.Errorf("invalid investmentID: %w", err) + } + c.InvestmentID = investmentID.Uint64() + + return nil +} diff --git a/pkg/core/state/vita.go b/pkg/core/state/vita.go index 69d56bf..0f827a0 100644 --- a/pkg/core/state/vita.go +++ b/pkg/core/state/vita.go @@ -65,6 +65,7 @@ type Vita struct { Status TokenStatus // Current status StatusReason string // Reason for status change RecoveryHash []byte // Hash of recovery mechanism + VestedUntil uint32 // Block height until which the Vita is vesting (Sybil resistance) } // ToStackItem implements stackitem.Convertible interface. @@ -79,6 +80,7 @@ func (t *Vita) ToStackItem() (stackitem.Item, error) { stackitem.NewBigInteger(big.NewInt(int64(t.Status))), stackitem.NewByteArray([]byte(t.StatusReason)), stackitem.NewByteArray(t.RecoveryHash), + stackitem.NewBigInteger(big.NewInt(int64(t.VestedUntil))), }), nil } @@ -88,8 +90,8 @@ func (t *Vita) FromStackItem(item stackitem.Item) error { if !ok { return errors.New("not a struct") } - if len(items) != 9 { - return fmt.Errorf("wrong number of elements: expected 9, got %d", len(items)) + if len(items) != 10 { + return fmt.Errorf("wrong number of elements: expected 10, got %d", len(items)) } tokenID, err := items[0].TryInteger() @@ -147,6 +149,12 @@ func (t *Vita) FromStackItem(item stackitem.Item) error { return fmt.Errorf("invalid recoveryHash: %w", err) } + vestedUntil, err := items[9].TryInteger() + if err != nil { + return fmt.Errorf("invalid vestedUntil: %w", err) + } + t.VestedUntil = uint32(vestedUntil.Int64()) + return nil }