diff --git a/airdrop/airdrop.gno b/airdrop/airdrop.gno index c382697..8a95f29 100644 --- a/airdrop/airdrop.gno +++ b/airdrop/airdrop.gno @@ -7,6 +7,8 @@ import ( "gno.land/p/demo/grc/grc20" "gno.land/p/demo/uint256" + + "gno.land/r/governance/snapshot" ) var ( @@ -35,19 +37,21 @@ type Config struct { // Airdrop represents the airdrop contract type Airdrop struct { - token grc20.Token - config Config - claimedBitmap map[uint64]uint64 - address std.Address + token grc20.Token + config Config + claimedBitmap map[uint64]uint64 // claimID -> bitmap + address std.Address + snapshotManager *snapshot.Manager } // NewAirdrop creates a new Airdrop instance func NewAirdrop(token grc20.Token, config Config, addr std.Address) *Airdrop { return &Airdrop{ - token: token, - config: config, - claimedBitmap: make(map[uint64]uint64), - address: addr, + token: token, + config: config, + claimedBitmap: make(map[uint64]uint64), + address: addr, + snapshotManager: snapshot.NewManager(), } } @@ -185,3 +189,19 @@ func (a *Airdrop) GetConfig() Config { func (a *Airdrop) GetAddress() std.Address { return a.address } + +// CreateSnapshot creates a new snapshot of the current state +func (a *Airdrop) CreateSnapshot(caller string) uint64 { + callerAddr := std.Address(caller) + return a.snapshotManager.CreateSnapshot(a.claimedBitmap, a.token.BalanceOf(callerAddr)) +} + +// IsClaimedAtSnapshot checks if a claim was made at a specific snapshot +func (a *Airdrop) IsClaimedAtSnapshot(snapshotID uint64, claimID uint64) (bool, error) { + return a.snapshotManager.IsClaimedAtSnapshot(snapshotID, claimID) +} + +// GetSnapshot retrieves a specific snapshot +func (a *Airdrop) GetSnapshot(id uint64) (snapshot.Snapshot, error) { + return a.snapshotManager.GetSnapshot(id) +} diff --git a/airdrop/airdrop_test.gno b/airdrop/airdrop_test.gno index fa3fad2..e13c5a3 100644 --- a/airdrop/airdrop_test.gno +++ b/airdrop/airdrop_test.gno @@ -13,6 +13,8 @@ import ( "gno.land/p/demo/uint256" ) +var testAddress = testutils.TestAddress("test") + // mockGRC20 is a mock implementation of the IGRC20 interface for testing type mockGRC20 struct { balances map[std.Address]uint64 @@ -89,8 +91,7 @@ func (m *mockGRC20) TransferFrom(from, to std.Address, amount uint64) error { /////////////////////////////////////////////////////////////////////////////////////// func TestClaimIDToBitmapIndex(t *testing.T) { - address := std.Address("test") - a := NewAirdrop(nil, Config{}, address) + a := NewAirdrop(nil, Config{}, testAddress) tests := []struct { claimID uint64 @@ -114,8 +115,7 @@ func TestClaimIDToBitmapIndex(t *testing.T) { } func TestIsClaimed(t *testing.T) { - address := std.Address("test") - a := NewAirdrop(nil, Config{}, address) + a := NewAirdrop(nil, Config{}, testAddress) // Test unclaimed if a.IsClaimed(0) { @@ -137,10 +137,9 @@ func TestIsClaimed(t *testing.T) { } func TestClaim(t *testing.T) { - address := std.Address("test") mockToken := NewMockGRC20("Test Token", "TST", 18) - airdrop := NewAirdrop(mockToken, Config{}, address) + airdrop := NewAirdrop(mockToken, Config{}, testAddress) // Set up some initial balances airdropAddress := std.GetOrigCaller() @@ -199,11 +198,10 @@ func TestClaim(t *testing.T) { } func TestClaim64(t *testing.T) { - address := std.Address("test") mockToken := NewMockGRC20("Test Token", "TST", 18) // Create a new Airdrop instance - airdrop := NewAirdrop(mockToken, Config{}, address) + airdrop := NewAirdrop(mockToken, Config{}, testAddress) // Set up some initial balances airdropAddress := std.GetOrigCaller() @@ -292,59 +290,155 @@ func TestClaim64(t *testing.T) { } func TestRefund(t *testing.T) { - token := NewMockGRC20("Test Token", "TST", 18) + mockToken := NewMockGRC20("Test Token", "TST", 18) refundTo := testutils.TestAddress("refund_to") - config := Config{ - RefundableTimestamp: uint64(time.Now().Add(time.Hour).Unix()), - RefundTo: refundTo, - } - airdrop := NewAirdrop(token, config, testutils.TestAddress("airdrop")) + airdropAddr := testutils.TestAddress("airdrop") + + // Set up initial balances + mockToken.balances[airdropAddr] = 1000000 - // Set some balance for the airdrop contract - token.balances[airdrop.address] = 1000 + t.Run("Refund not allowed", func(t *testing.T) { + config := Config{ + RefundableTimestamp: 0, + RefundTo: refundTo, + } + airdrop := NewAirdrop(mockToken, config, airdropAddr) - t.Run("Refund before refundable timestamp", func(t *testing.T) { defer func() { if r := recover(); r == nil { - t.Errorf("Expected panic but got none") + t.Errorf("The code did not panic") + } else if r != ErrRefundNotAllowed { + t.Errorf("Expected ErrRefundNotAllowed, got %v", r) + } + }() + airdrop.Refund() + }) + + t.Run("Refund too early", func(t *testing.T) { + config := Config{ + RefundableTimestamp: uint64(time.Now().Add(time.Hour).Unix()), + RefundTo: refundTo, + } + airdrop := NewAirdrop(mockToken, config, airdropAddr) + + defer func() { + if r := recover(); r == nil { + t.Errorf("The code did not panic") } else if r != ErrTooEarly { - t.Errorf("Expected ErrTooEarly panic but got: %v", r) + t.Errorf("Expected ErrTooEarly, got %v", r) + } + }() + airdrop.Refund() + }) + + t.Run("No balance to refund", func(t *testing.T) { + config := Config{ + RefundableTimestamp: uint64(time.Now().Add(-time.Hour).Unix()), + RefundTo: refundTo, + } + airdrop := NewAirdrop(mockToken, config, airdropAddr) + mockToken.balances[airdropAddr] = 0 + + defer func() { + if r := recover(); r == nil { + t.Errorf("The code did not panic") + } else if r != ErrNoBalance { + t.Errorf("Expected ErrNoBalance, got %v", r) } }() airdrop.Refund() }) t.Run("Successful refund", func(t *testing.T) { - // Set current time to after refundable timestamp - oldTimeNow := timeNow - timeNow = func() time.Time { - return time.Unix(int64(config.RefundableTimestamp+1), 0) + config := Config{ + RefundableTimestamp: uint64(time.Now().Add(-time.Hour).Unix()), + RefundTo: refundTo, } - defer func() { timeNow = oldTimeNow }() + airdrop := NewAirdrop(mockToken, config, airdropAddr) + mockToken.balances[airdropAddr] = 1000000 err := airdrop.Refund() if err != nil { t.Errorf("Unexpected error: %v", err) } - if balance := token.balances[refundTo]; balance != 1000 { - t.Errorf("Expected refund balance to be 1000, got %d", balance) + if mockToken.balances[refundTo] != 1000000 { + t.Errorf("Expected refund balance to be 1000000, got %d", mockToken.balances[refundTo]) } - if balance := token.balances[airdrop.address]; balance != 0 { - t.Errorf("Expected airdrop balance to be 0, got %d", balance) + if mockToken.balances[airdropAddr] != 0 { + t.Errorf("Expected airdrop balance to be 0, got %d", mockToken.balances[airdropAddr]) } }) +} + +func TestAirdropWithSnapshot(t *testing.T) { + mockToken := NewMockGRC20("Test Token", "TST", 18) + + airdrop := NewAirdrop(mockToken, Config{}, testAddress) - // t.Run("Refund with no balance", func(t *testing.T) { - // token.balances[airdrop.address] = 0 - // defer func() { - // if r := recover(); r == nil { - // t.Errorf("Expected panic but got none") - // } else if r != ErrNoBalance { - // t.Errorf("Expected ErrNoBalance panic but got: %v", r) - // } - // }() - // airdrop.Refund() - // }) + // Set up some initial balances + airdropAddress := std.GetOrigCaller() + mockToken.balances[airdropAddress] = 1000000 // Initial balance of airdrop contract + + // Create 128 claims (2 batches of 64) + claims := make([]Claim, 128) + for i := 0; i < 128; i++ { + claims[i] = Claim{ + ID: uint64(i * 64), // Ensure first claim ID is multiple of 64 + Claimee: testutils.TestAddress(ufmt.Sprintf("claimee%d", i)), + Amount: uint256.NewUint(1000), // Each claim is for 1000 tokens + } + } + + // Process first batch of claims + claimed, _ := airdrop.Claim64(claims[:64]) + if claimed != 64 { + t.Errorf("Expected 64 claims, got %d", claimed) + } + + // Create a snapshot after first batch + caller := airdropAddress.String() + snapshotID := airdrop.CreateSnapshot(caller) + + // Process second batch of claims + claimed, _ = airdrop.Claim64(claims[64:]) + if claimed != 64 { + t.Errorf("Expected 64 claims, got %d", claimed) + } + + // Check balances after all claims + airdropBalance := mockToken.BalanceOf(airdropAddress) + if airdropBalance != 872000 { // 1000000 - (128 * 1000) + t.Errorf("Expected airdrop balance to be 872000, got %d", airdropBalance) + } + + // Test snapshot state (simplified) + sampleClaimIDs := []uint64{0, 63, 64, 127} + for _, claimID := range sampleClaimIDs { + expectedClaimed := claimID < 64 + isClaimed, err := airdrop.IsClaimedAtSnapshot(snapshotID, claimID) + if err != nil { + t.Errorf("Unexpected error checking claim status: %v", err) + } + if isClaimed != expectedClaimed { + t.Errorf("For claim %d, expected claimed status %v, got %v", claimID, expectedClaimed, isClaimed) + } + } + + // Test invalid snapshot ID + _, err := airdrop.IsClaimedAtSnapshot(snapshotID+1, 0) + if err == nil { + t.Error("Expected error for invalid snapshot ID, got nil") + } + + // Get snapshot and check remaining tokens + snapshot, err := airdrop.GetSnapshot(snapshotID) + if err != nil { + t.Errorf("Unexpected error getting snapshot: %v", err) + } + expectedRemainingTokens := uint64(936000) // 1000000 - (64 * 1000) + if snapshot.RemainingTokens != expectedRemainingTokens { + t.Errorf("Expected remaining tokens %d, got %d", expectedRemainingTokens, snapshot.RemainingTokens) + } } diff --git a/airdrop/gno.mod b/airdrop/gno.mod new file mode 100644 index 0000000..d09f26b --- /dev/null +++ b/airdrop/gno.mod @@ -0,0 +1,9 @@ +module gno.land/r/governance/airdrop + +require ( + gno.land/p/demo/grc/grc20 v0.0.0-latest + gno.land/p/demo/merkle v0.0.0-latest + gno.land/p/demo/testutils v0.0.0-latest + gno.land/p/demo/ufmt v0.0.0-latest + gno.land/p/demo/uint256 v0.0.0-latest +) diff --git a/gno.mod b/gno.mod deleted file mode 100644 index 6540134..0000000 --- a/gno.mod +++ /dev/null @@ -1 +0,0 @@ -module gno.land/r/demo/governance diff --git a/snapshot/README.md b/snapshot/README.md new file mode 100644 index 0000000..24331e0 --- /dev/null +++ b/snapshot/README.md @@ -0,0 +1,66 @@ +# Snapshot + +## Overview + +The snapshot package provides functionality to capture and mange the state of a system at specific points in time. This package allows for creating, storing, and retrieving snapshots that include claimed bitmaps and the number of remaining tokens. + +## Features + +- Create and store snapshots +- Retrieve snapshots by ID +- Check claim status at a specific snapshot point + +## How it Works + +1. The `Manager` type manages a list of snapshots and the last snapshot ID. +2. Each new snapshot is assigned a unique ID and timestamped with the current time. +3. Each snapshot stores a claimed bitmap[^1] and the number of remaining tokens. + +## Example + +```go +package main + +import ( + "gno.land/p/demo/ufmt" + "gno.land/r/governance/snapshot" +) + +func main() { + // Create a new Manager + manager := snapshot.NewManager() + + // Create a snapshot + claimedBitmap := map[uint64]uint64{1: 5} + remainingTokens := uint64(1000) + id := manager.CreateSnapshot(claimedBitmap, remainingTokens) + + println(ufmt.Sprintf("Created snapshot with ID: %d\n", id)) + + // Retrieve a snapshot + snap, err := manager.GetSnapshot(id) + if err != nil { + fmt.Printf("Error retrieving snapshot: %v\n", err) + return + } + + res := ufmt.Sprintf("Retrieved snapshot: ID=%d, Timestamp=%d, RemainingTokens=%d\n", + snap.ID, snap.Timestamp, snap.RemainingTokens) + println(res) + + // Check a specific claim status + claimed, err := manager.IsClaimedAtSnapshot(id, 64) + if err != nil { + fmt.Printf("Error checking claim: %v\n", err) + return + } + + println(ufmt.Printf("Claim 64 is claimed: %v\n", claimed)) +} +``` + +## License + +This package is licensed under the MIT License. See the [LICENSE](LICENSE) file for more information. + +[^1]: The bitmap is represented as a map of `uint64` value, where each bit represents a state of specific claim. diff --git a/snapshot/gno.mod b/snapshot/gno.mod new file mode 100644 index 0000000..606b22e --- /dev/null +++ b/snapshot/gno.mod @@ -0,0 +1 @@ +module gno.land/r/governance/snapshot diff --git a/snapshot/snapshot.gno b/snapshot/snapshot.gno new file mode 100644 index 0000000..a2b21ce --- /dev/null +++ b/snapshot/snapshot.gno @@ -0,0 +1,84 @@ +package snapshot + +import ( + "errors" + "time" +) + +// Snapshot represents a point-in-time capture of the state of a system. +type Snapshot struct { + ID uint64 + Timestamp int64 + ClaimedBitmap map[uint64]uint64 + RemainingTokens uint64 +} + +// Manager manages the creation and retrieval of snapshots. +type Manager struct { + snapshots []Snapshot + lastSnapshotID uint64 +} + +func NewManager() *Manager { + return &Manager{ + snapshots: make([]Snapshot, 0), + lastSnapshotID: 0, + } +} + +// CreateSnapshot generates a new snapshot with the given claimed bitmap and remaining tokens. +// +// It returns the ID of the newly created snapshot. +func (m *Manager) CreateSnapshot(claimedBitmap map[uint64]uint64, remainingTokens uint64) uint64 { + m.lastSnapshotID++ + snapshot := Snapshot{ + ID: m.lastSnapshotID, + Timestamp: time.Now().Unix(), + ClaimedBitmap: make(map[uint64]uint64), + RemainingTokens: remainingTokens, + } + + // deep copy for current claimedBitmap + for k, v := range claimedBitmap { + snapshot.ClaimedBitmap[k] = v + } + + m.snapshots = append(m.snapshots, snapshot) + return m.lastSnapshotID +} + +// GetSnapshot retrieves a snapshot by its ID. +// +// It returns the snapshot if found, otherwise an error. +func (m *Manager) GetSnapshot(id uint64) (Snapshot, error) { + for _, snapshot := range m.snapshots { + if snapshot.ID == id { + return snapshot, nil + } + } + return Snapshot{}, errors.New("snapshot not found") +} + +// IsClaimedAtSnapshot checks if a specifix claim was made at the time of a given snapshot. +// +// It returns true if the claim was made, false if not. +func (m *Manager) IsClaimedAtSnapshot(snapshotID, claimID uint64) (bool, error) { + snapshot, err := m.GetSnapshot(snapshotID) + if err != nil { + return false, err + } + + word, index := claimIDToBitmapIndex(claimID) + bitmap, exists := snapshot.ClaimedBitmap[word] + if !exists { + return false, nil + } + + return bitmap&(1< after { + t.Errorf("Expected timestamp between %d and %d, got %d", before, after, snapshot.Timestamp) + } +} diff --git a/staker/gno.mod b/staker/gno.mod new file mode 100644 index 0000000..ee56d33 --- /dev/null +++ b/staker/gno.mod @@ -0,0 +1,8 @@ +module gno.land/r/governance/staker + +require ( + gno.land/p/demo/grc/grc20 v0.0.0-latest + gno.land/p/demo/testutils v0.0.0-latest + gno.land/p/demo/ufmt v0.0.0-latest + gno.land/p/demo/uint256 v0.0.0-latest +)