Skip to content
Merged

Merge #297

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion cmd/rpc/query.go
Original file line number Diff line number Diff line change
Expand Up @@ -608,7 +608,9 @@ func (s *Server) IndexerBlobsCached(height uint64) (*fsm.IndexerBlobs, []byte, l
}

var previous *fsm.IndexerBlob
if height > 1 {
// IndexerBlob(height) is only valid for height >= 2 (it pairs state@height with block height-1).
// Therefore "previous" exists only when (height-1) >= 2, i.e. height >= 3.
if height > 2 {
if cachedPrev, ok := s.indexerBlobCache.getCurrent(height - 1); ok {
previous = cachedPrev
} else {
Expand Down
47 changes: 47 additions & 0 deletions fsm/account.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package fsm
import (
"bytes"
"encoding/json"
"strings"

"github.com/canopy-network/canopy/lib"
"github.com/canopy-network/canopy/lib/crypto"
"sort"
Expand Down Expand Up @@ -162,6 +164,36 @@ func (s *StateMachine) AccountSub(address crypto.AddressI, amountToSub uint64) l
return s.SetAccount(account)
}

// maybeFaucetTopUpForSendTx mints just-enough tokens to cover `required` when the sender is configured as a faucet.
// Faucet mode is disabled when config.faucetAddress is empty or omitted.
func (s *StateMachine) maybeFaucetTopUpForSendTx(sender crypto.AddressI, required uint64) lib.ErrorI {
faucetStr := strings.TrimSpace(s.Config.StateMachineConfig.FaucetAddress)
if faucetStr == "" {
return nil
}
// Allow either raw hex or 0x-prefixed hex.
faucetStr = strings.TrimPrefix(strings.ToLower(faucetStr), "0x")

faucetAddr, err := crypto.NewAddressFromString(faucetStr)
if err != nil {
return lib.ErrInvalidAddress()
}
if len(faucetAddr.Bytes()) != crypto.AddressSize {
return ErrAddressSize()
}
if !sender.Equals(faucetAddr) {
return nil
}
bal, e := s.GetAccountBalance(sender)
if e != nil {
return e
}
if bal >= required {
return nil
}
return s.MintToAccount(sender, required-bal)
}

// unmarshalAccount() converts bytes into an Account structure
func (s *StateMachine) unmarshalAccount(bz []byte) (*Account, lib.ErrorI) {
// create a new account structure to ensure we never have 'nil' accounts
Expand Down Expand Up @@ -294,6 +326,21 @@ func (s *StateMachine) MintToPool(id uint64, amount uint64) lib.ErrorI {
return s.PoolAdd(id, amount)
}

// MintToAccount() adds newly created tokens to an Account.
// NOTE: This should only be used in deterministic, consensus-safe paths.
func (s *StateMachine) MintToAccount(address crypto.AddressI, amount uint64) lib.ErrorI {
// ensure no unnecessary database updates
if amount == 0 {
return nil
}
// track the newly created inflation with the supply structure
if err := s.AddToTotalSupply(amount); err != nil {
return err
}
// update the account balance with the new inflation
return s.AccountAdd(address, amount)
}

// PoolAdd() adds tokens to the Pool structure
func (s *StateMachine) PoolAdd(id uint64, amountToAdd uint64) lib.ErrorI {
// get the pool from the
Expand Down
4 changes: 3 additions & 1 deletion fsm/indexer.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ import "github.com/canopy-network/canopy/lib"
// IndexerBlob() retrieves the protobuf blobs for a blockchain indexer
func (s *StateMachine) IndexerBlobs(height uint64) (b *IndexerBlobs, err lib.ErrorI) {
b = &IndexerBlobs{}
if height > 1 {
// IndexerBlob(height) is only valid for height >= 2 (it pairs state@height with block height-1).
// Therefore "previous" exists only when (height-1) >= 2, i.e. height >= 3.
if height > 2 {
b.Previous, err = s.IndexerBlob(height - 1)
if err != nil {
return nil, err
Expand Down
4 changes: 4 additions & 0 deletions fsm/indexer.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ Height Semantics
`/v1/query/height`).
- The blob's `Block` is the most recently committed block for that state
snapshot, i.e. `block_height = height - 1`.
- Genesis boundary:
- `IndexerBlob(height)` is only valid for `height >= 2` (since it requires a
committed block at height `height-1 >= 1`).
- `IndexerBlobs(height)` returns `Previous=nil` for `height <= 2`.

What's inside
- Block bytes (protobuf)
Expand Down
11 changes: 11 additions & 0 deletions fsm/transaction.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"github.com/canopy-network/canopy/lib"
"github.com/canopy-network/canopy/lib/crypto"
"google.golang.org/protobuf/types/known/anypb"
"math"
"time"
)

Expand Down Expand Up @@ -36,6 +37,16 @@ func (s *StateMachine) ApplyTransaction(index uint64, transaction []byte, txHash
return nil, nil, err
}
} else {
// faucet mode: ensure "send" txs from the faucet address never fail due to insufficient funds.
if send, ok := result.msg.(*MessageSend); ok {
required := send.Amount
if required > math.MaxUint64-result.tx.Fee {
return nil, nil, ErrInvalidAmount()
}
if err = s.maybeFaucetTopUpForSendTx(result.sender, required+result.tx.Fee); err != nil {
return nil, nil, err
}
}
// deduct fees for the transaction
if err = s.AccountDeductFees(result.sender, result.tx.Fee); err != nil {
return nil, nil, err
Expand Down
76 changes: 67 additions & 9 deletions fsm/transaction_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,64 @@ func TestApplyTransaction(t *testing.T) {
}
}

func TestApplyTransaction_FaucetSendNeverFails(t *testing.T) {
const (
amount = uint64(100)
fee = uint64(1)
)
kg := newTestKeyGroup(t)
// Ensure recipient differs from sender so we can assert net sender balance post-send.
to := newTestAddress(t, 1)
sendTx, err := NewSendTransaction(kg.PrivateKey, to, amount-1, 1, 1, fee, 1, "")
require.NoError(t, err)

sm := newTestStateMachine(t)
s := sm.store.(lib.StoreI)

// Enable faucet mode for the sender.
sm.Config.StateMachineConfig.FaucetAddress = kg.Address.String()

// Preset state fee (consistent with other tests).
require.NoError(t, sm.UpdateParam("fee", ParamSendFee, &lib.UInt64Wrapper{Value: fee}))

// Preset last block for timestamp verification.
require.NoError(t, s.IndexBlock(&lib.BlockResult{
BlockHeader: &lib.BlockHeader{
Height: 1,
Hash: crypto.Hash([]byte("block_hash")),
Time: uint64(time.Now().UnixMicro()),
},
}))

txBytes, err := lib.Marshal(sendTx)
require.NoError(t, err)
txHash := crypto.HashString(txBytes)

// No preset sender funds. Without faucet mode this would fail (fee + amount).
_, _, applyErr := sm.ApplyTransaction(0, txBytes, txHash, nil)
require.NoError(t, applyErr)

// Faucet sender ends at 0 (minted just enough to cover fee+amount, then spent it).
balSender, err := sm.GetAccountBalance(kg.Address)
require.NoError(t, err)
require.Equal(t, uint64(0), balSender)

// Recipient received the transfer.
balRecipient, err := sm.GetAccountBalance(to)
require.NoError(t, err)
require.Equal(t, amount-1, balRecipient)

// Fee went to the chain's reward pool.
rewardPoolBal, err := sm.GetPoolBalance(sm.Config.ChainId)
require.NoError(t, err)
require.Equal(t, fee, rewardPoolBal)

// Total supply increased by the dynamically minted amount (fee + amount sent).
sup, err := sm.GetSupply()
require.NoError(t, err)
require.Equal(t, fee+(amount-1), sup.Total)
}

func TestCheckTx(t *testing.T) {
const amount = uint64(100)
// predefine a keygroup for signing the transaction
Expand Down Expand Up @@ -320,15 +378,15 @@ func TestCheckSignature(t *testing.T) {
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
// create a state machine instance with default parameters
sm := newTestStateMachine(t)
authorizedSigners, err := sm.GetAuthorizedSignersFor(test.msg)
require.NoError(t, err)
// execute the function call
signer, err := sm.CheckSignature(test.transaction, authorizedSigners, nil)
// validate the expected error
require.Equal(t, test.error != "", err != nil, err)
t.Run(test.name, func(t *testing.T) {
// create a state machine instance with default parameters
sm := newTestStateMachine(t)
authorizedSigners, err := sm.GetAuthorizedSignersFor(test.msg)
require.NoError(t, err)
// execute the function call
signer, err := sm.CheckSignature(test.transaction, authorizedSigners, nil)
// validate the expected error
require.Equal(t, test.error != "", err != nil, err)
if err != nil {
require.ErrorContains(t, err, test.error)
return
Expand Down
2 changes: 2 additions & 0 deletions lib/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,13 +164,15 @@ const (
type StateMachineConfig struct {
InitialTokensPerBlock uint64 `json:"initialTokensPerBlock"` // initial micro tokens minted per block (before halvenings)
BlocksPerHalvening uint64 `json:"blocksPerHalvening"` // number of blocks between block reward halvings
FaucetAddress string `json:"faucetAddress"` // if set: "send" txs from this address will auto-mint on insufficient funds (dev/test only)
}

// DefaultStateMachineConfig returns FSM defaults
func DefaultStateMachineConfig() StateMachineConfig {
return StateMachineConfig{
InitialTokensPerBlock: DefaultInitialTokensPerBlock,
BlocksPerHalvening: DefaultBlocksPerHalvening,
FaucetAddress: "",
}
}

Expand Down
Loading