@leofcoin/contracts 0.1.22 → 0.1.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leofcoin/contracts",
3
- "version": "0.1.22",
3
+ "version": "0.1.24",
4
4
  "description": "",
5
5
  "type": "module",
6
6
  "exports": {
@@ -28,8 +28,7 @@
28
28
  },
29
29
  "homepage": "https://github.com/leofcoin/contracts#readme",
30
30
  "dependencies": {
31
- "@leofcoin/standards": "^0.3.6",
32
- "lucky-numbers": "^1.2.1"
31
+ "@leofcoin/standards": "^0.3.7"
33
32
  },
34
33
  "devDependencies": {
35
34
  "@leofcoin/types": "^1.0.26",
@@ -1,16 +1,133 @@
1
1
  import Token, { TokenState } from '@leofcoin/standards/token.js'
2
2
 
3
+ interface GenesisAllocation {
4
+ address: address
5
+ amount: bigint | string
6
+ }
7
+ interface LeofcoinState extends TokenState {
8
+ initialSupply: bigint
9
+ targetSupply: bigint
10
+ }
11
+
12
+ export const MONETARY_POLICY_AUTHORITY = 'leofcoin:protocol:monetary-policy:v1'
13
+
14
+ const genesisState = (initialSupply: bigint, allocations: GenesisAllocation[]): TokenState => {
15
+ initialSupply = BigInt(initialSupply)
16
+ if (initialSupply <= 0n) throw new Error('initial supply must be positive')
17
+ if (!Array.isArray(allocations) || allocations.length === 0)
18
+ throw new Error('genesis allocations are required')
19
+
20
+ const balances: { [address: address]: bigint } = {}
21
+ let allocated = 0n
22
+ for (const allocation of [...allocations].sort((left, right) =>
23
+ left.address.localeCompare(right.address)
24
+ )) {
25
+ if (!allocation.address || balances[allocation.address] !== undefined)
26
+ throw new Error('invalid or duplicate genesis address')
27
+ const amount = BigInt(allocation.amount)
28
+ if (amount <= 0n) throw new Error('genesis allocation must be positive')
29
+ balances[allocation.address] = amount
30
+ allocated += amount
31
+ }
32
+ if (allocated !== initialSupply) {
33
+ throw new Error(
34
+ `genesis allocations must equal initial supply: expected ${initialSupply}, got ${allocated}`
35
+ )
36
+ }
37
+
38
+ return {
39
+ creator: msg.sender,
40
+ createdAt: BigInt(Date.now()),
41
+ roles: {
42
+ OWNER: [msg.sender],
43
+ MINT: [MONETARY_POLICY_AUTHORITY],
44
+ BURN: [MONETARY_POLICY_AUTHORITY]
45
+ },
46
+ holders: BigInt(Object.keys(balances).length),
47
+ balances,
48
+ approvals: {},
49
+ totalSupply: initialSupply
50
+ }
51
+ }
52
+
3
53
  export default class Leofcoin extends Token {
4
- constructor(state: TokenState) {
5
- super('Leofcoin', 'LFC', 18, state)
6
- // The machine executes protocol fee burns as the immutable contract creator.
7
- // Persist this capability in fresh genesis state; restored legacy state is
8
- // intentionally left untouched and cannot activate fee burning.
9
- if (!state && !this.hasRole(msg.sender, 'BURN')) this.grantRole(msg.sender, 'BURN')
54
+ #targetSupply: bigint
55
+ #initialSupply: bigint
56
+
57
+ constructor(
58
+ targetSupply: bigint,
59
+ initialSupplyOrState: bigint | LeofcoinState,
60
+ allocations: GenesisAllocation[] = [],
61
+ restoredState?: LeofcoinState
62
+ ) {
63
+ const legacyState =
64
+ initialSupplyOrState &&
65
+ typeof initialSupplyOrState === 'object' &&
66
+ !Array.isArray(initialSupplyOrState)
67
+ ? initialSupplyOrState
68
+ : undefined
69
+ const state = restoredState || legacyState
70
+ const initialSupply = state
71
+ ? BigInt(state.initialSupply ?? state.totalSupply)
72
+ : BigInt(initialSupplyOrState as bigint)
73
+ super('Leofcoin', 'LFC', 18, state || genesisState(initialSupply, allocations))
74
+ this.#targetSupply = state ? BigInt(state.targetSupply) : BigInt(targetSupply)
75
+ this.#initialSupply = initialSupply
76
+ if (this.#targetSupply <= 0n) throw new Error('target supply must be positive')
77
+ if (this.#initialSupply <= 0n || this.#initialSupply > this.#targetSupply) {
78
+ throw new Error('initial supply must be positive and not exceed target supply')
79
+ }
80
+ }
81
+
82
+ get targetSupply() {
83
+ return this.#targetSupply
84
+ }
85
+ get initialSupply() {
86
+ return this.#initialSupply
87
+ }
88
+ get name() {
89
+ return 'Leofcoin'
90
+ }
91
+ get symbol() {
92
+ return 'LFC'
93
+ }
94
+ get decimals() {
95
+ return 18
96
+ }
97
+
98
+ get state(): LeofcoinState {
99
+ return {
100
+ ...super.state,
101
+ initialSupply: this.#initialSupply,
102
+ targetSupply: this.#targetSupply
103
+ } as LeofcoinState
104
+ }
105
+
106
+ grantRole(address: address, role: string) {
107
+ if (role === 'MINT' || role === 'BURN') throw new Error('protocol monetary roles are immutable')
108
+ return super.grantRole(address, role)
109
+ }
110
+
111
+ revokeRole(address: address, role: string) {
112
+ if (role === 'MINT' || role === 'BURN') throw new Error('protocol monetary roles are immutable')
113
+ return super.revokeRole(address, role)
114
+ }
115
+
116
+ mint(to: address, amount: bigint) {
117
+ if (msg.sender !== MONETARY_POLICY_AUTHORITY)
118
+ throw new Error('only the monetary protocol may mint')
119
+ amount = BigInt(amount)
120
+ if (amount <= 0n) throw new Error('mint amount must be positive')
121
+ if (this.totalSupply + amount > this.#targetSupply)
122
+ throw new Error('mint exceeds target supply')
123
+ return super.mint(to, amount)
10
124
  }
11
125
 
12
126
  burn(from: address, amount: bigint) {
13
- // Prevent balance underflow when burning
127
+ if (msg.sender !== MONETARY_POLICY_AUTHORITY)
128
+ throw new Error('only the monetary protocol may burn')
129
+ amount = BigInt(amount)
130
+ if (amount <= 0n) throw new Error('burn amount must be positive')
14
131
  if (this.balanceOf(from) < amount) throw new Error('amount exceeds balance')
15
132
  return super.burn(from, amount)
16
133
  }
package/src/validators.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { lottery } from 'lucky-numbers'
2
1
  import Roles from '@leofcoin/standards/roles.js'
3
2
  import type { RolesState } from '@leofcoin/standards/interfaces.js'
4
3
 
@@ -142,34 +141,13 @@ export default class Validators extends Roles {
142
141
  }
143
142
 
144
143
  shuffleValidator() {
145
- // todo introduce voting mechanism
146
- // select amount of peers to vote & pass when 2/3 select the same peer
147
- // this.vote
148
- // todo only ids should be accessable
149
- const _peers = state.peers
150
- const peers = _peers
151
- // only validators make a chance
152
- .filter((peer) => this.#validators.includes(peer[0]))
153
- // add up the bytes
154
- .map((peer) => {
155
- peer[1].totalBytes = peer[1].bw.up + peer[1].bw.down
156
- return peer
157
- })
158
- .sort((a, b) => b[1].totalBytes - a[1].totalBytes)
159
- // only return 128 best participating max
160
- .splice(0, _peers.length > 128 ? 128 : _peers.length)
161
-
162
- if (peers.length === 0) return
163
-
164
- const luckyNumber = peers.length === 1 ? [0] : lottery(1, peers.length - 1)
165
-
166
- let nextValidator = peers[luckyNumber[0]][0]
167
- // redraw when the validator is the same
168
- // but keep the net alive when only one validator is found
169
- if (this.#currentValidator === nextValidator && peers.length !== 1) {
170
- const luckyNumber = lottery(1, peers.length - 1)
171
- nextValidator = peers[luckyNumber[0]][0]
172
- }
173
- this.#currentValidator = nextValidator
144
+ const validators = [...new Set(this.#validators)].sort()
145
+ if (validators.length === 0) return
146
+ const seed = `${state.lastBlock?.hash ?? '0x0'}:${state.lastBlock?.index ?? -1}`
147
+ let value = 0n
148
+ for (const character of seed) value = (value * 31n + BigInt(character.charCodeAt(0))) % 4_294_967_291n
149
+ let index = Number(value % BigInt(validators.length))
150
+ if (validators.length > 1 && validators[index] === this.#currentValidator) index = (index + 1) % validators.length
151
+ this.#currentValidator = validators[index]
174
152
  }
175
153
  }
@@ -1,89 +1,130 @@
1
1
  import { describe, it, beforeEach } from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
- import Leofcoin from './../exports/native-token.js'
3
+ import Leofcoin, { MONETARY_POLICY_AUTHORITY } from './../exports/native-token.js'
4
4
 
5
5
  describe('Leofcoin', () => {
6
6
  let token
7
7
  const receiverAddress = '0xReceiverAddress'
8
8
  const otherReceiverAddress = '0xOtherReceiverAddress'
9
9
  const ownerAddress = '0xOwnerAddress'
10
+ const targetSupply = 10_000_000n
11
+ const initialSupply = 1_000_000n
12
+ const allocations = [
13
+ { address: receiverAddress, amount: 400_000n },
14
+ { address: ownerAddress, amount: 600_000n }
15
+ ]
10
16
 
11
17
  beforeEach(() => {
12
18
  global.msg = {
13
19
  sender: ownerAddress,
14
20
  contract: '0xContractAddress',
15
- staticCall: async (currency, method, args) => {
16
- // Mock implementation of msg.staticCall
17
- },
18
- call: async (currency, method, args) => {
19
- // Mock implementation of msg.call
20
- }
21
+ staticCall: async () => {},
22
+ call: async () => {}
21
23
  }
22
24
 
23
- token = new Leofcoin()
25
+ token = new Leofcoin(targetSupply, initialSupply, allocations)
24
26
  })
25
27
 
26
- it('should create an instance of Leofcoin', () => {
28
+ it('creates the complete initial supply directly from deterministic allocations', () => {
27
29
  assert.ok(token instanceof Leofcoin)
28
- })
29
-
30
- it('should have correct properties', () => {
31
30
  assert.equal(token.name, 'Leofcoin')
32
31
  assert.equal(token.symbol, 'LFC')
33
32
  assert.equal(token.decimals, 18)
33
+ assert.equal(token.targetSupply, targetSupply)
34
+ assert.equal(token.initialSupply, initialSupply)
35
+ assert.equal(token.totalSupply, initialSupply)
36
+ assert.equal(token.balanceOf(ownerAddress), 600_000n)
37
+ assert.equal(token.balanceOf(receiverAddress), 400_000n)
38
+ assert.equal(token.holders, 2n)
34
39
  })
35
40
 
36
- it('should grant MINT role to owner', async () => {
37
- await token.grantRole(ownerAddress, 'MINT')
38
- assert.equal(await token.hasRole(ownerAddress, 'MINT'), true)
41
+ it('sorts allocations so equivalent genesis inputs produce identical state', () => {
42
+ const reversed = new Leofcoin(targetSupply, initialSupply, [...allocations].reverse())
43
+ assert.deepEqual(reversed.balances, token.balances)
39
44
  })
40
45
 
41
- it('should be able to mint', async () => {
42
- await token.grantRole(ownerAddress, 'MINT')
43
- await token.mint(ownerAddress, 100000n)
44
- assert.equal(token.balanceOf(ownerAddress), 100000n)
46
+ it('persists the immutable supplies when restoring state', () => {
47
+ msg.sender = MONETARY_POLICY_AUTHORITY
48
+ token.mint(ownerAddress, 100n)
49
+ const restored = new Leofcoin(999n, token.state)
50
+ assert.equal(restored.targetSupply, targetSupply)
51
+ assert.equal(restored.initialSupply, initialSupply)
52
+ assert.equal(restored.totalSupply, initialSupply + 100n)
53
+ assert.equal(restored.balanceOf(ownerAddress), 600_100n)
45
54
  })
46
55
 
47
- it('should be able to transfer', async () => {
48
- await token.grantRole(ownerAddress, 'MINT')
49
- await token.mint(receiverAddress, 100000n)
50
- msg.sender = receiverAddress
51
- await token.transfer(receiverAddress, otherReceiverAddress, 10000n)
52
- assert.equal(token.balanceOf(receiverAddress), 90000n)
53
- assert.equal(token.balanceOf(otherReceiverAddress), 10000n)
56
+ it('reserves mint and burn authority for the monetary protocol', () => {
57
+ assert.equal(token.hasRole(ownerAddress, 'OWNER'), true)
58
+ assert.equal(token.hasRole(ownerAddress, 'MINT'), false)
59
+ assert.equal(token.hasRole(ownerAddress, 'BURN'), false)
60
+ assert.equal(token.hasRole(MONETARY_POLICY_AUTHORITY, 'MINT'), true)
61
+ assert.equal(token.hasRole(MONETARY_POLICY_AUTHORITY, 'BURN'), true)
54
62
  })
55
63
 
56
- it('should grant the genesis owner protocol burn authority', () => {
57
- assert.equal(token.hasRole(ownerAddress, 'BURN'), true)
64
+ it('prevents the owner from minting or burning', () => {
65
+ assert.throws(() => token.mint(ownerAddress, 1n), {
66
+ message: 'only the monetary protocol may mint'
67
+ })
68
+ assert.throws(() => token.burn(ownerAddress, 1n), {
69
+ message: 'only the monetary protocol may burn'
70
+ })
58
71
  })
59
72
 
60
- it('should burn balance and reduce total supply', async () => {
61
- await token.grantRole(ownerAddress, 'MINT')
62
- await token.mint(ownerAddress, 100000n)
63
- await token.burn(ownerAddress, 10000n)
64
- assert.equal(token.balanceOf(ownerAddress), 90000n)
65
- assert.equal(token.totalSupply, 90000n)
73
+ it('makes protocol monetary roles immutable', () => {
74
+ assert.throws(() => token.grantRole(ownerAddress, 'MINT'), {
75
+ message: 'protocol monetary roles are immutable'
76
+ })
77
+ assert.throws(() => token.revokeRole(MONETARY_POLICY_AUTHORITY, 'BURN'), {
78
+ message: 'protocol monetary roles are immutable'
79
+ })
66
80
  })
67
81
 
68
- it('should not be able to burn more than balance', async () => {
69
- await token.grantRole(ownerAddress, 'MINT')
70
- await token.mint(ownerAddress, 100000n)
82
+ it('allows protocol-controlled minting and burning', () => {
83
+ msg.sender = MONETARY_POLICY_AUTHORITY
84
+ token.mint(ownerAddress, 100_000n)
85
+ token.burn(ownerAddress, 10_000n)
86
+ assert.equal(token.balanceOf(ownerAddress), 690_000n)
87
+ assert.equal(token.totalSupply, 1_090_000n)
88
+ })
71
89
 
72
- await assert.rejects(async () => await token.burn(ownerAddress, 100001n), {
73
- message: 'amount exceeds balance'
74
- })
90
+ it('allows allocated holders to transfer', () => {
91
+ msg.sender = receiverAddress
92
+ token.transfer(receiverAddress, otherReceiverAddress, 10_000n)
93
+ assert.equal(token.balanceOf(receiverAddress), 390_000n)
94
+ assert.equal(token.balanceOf(otherReceiverAddress), 10_000n)
75
95
  })
76
96
 
77
- it('should not be able to mint if not MINT role', async () => {
78
- assert.throws(() => token.mint(ownerAddress, 100000n), { message: 'not allowed' })
97
+ it('rejects invalid genesis allocation sets', () => {
98
+ assert.throws(() => new Leofcoin(targetSupply, initialSupply, []), {
99
+ message: 'genesis allocations are required'
100
+ })
101
+ assert.throws(
102
+ () =>
103
+ new Leofcoin(targetSupply, initialSupply, [
104
+ { address: ownerAddress, amount: initialSupply - 1n }
105
+ ]),
106
+ { message: /genesis allocations must equal initial supply/ }
107
+ )
108
+ assert.throws(
109
+ () =>
110
+ new Leofcoin(targetSupply, initialSupply, [
111
+ { address: ownerAddress, amount: 500_000n },
112
+ { address: ownerAddress, amount: 500_000n }
113
+ ]),
114
+ { message: 'invalid or duplicate genesis address' }
115
+ )
116
+ assert.throws(() => new Leofcoin(1n, 2n, [{ address: ownerAddress, amount: 2n }]), {
117
+ message: 'initial supply must be positive and not exceed target supply'
118
+ })
79
119
  })
80
120
 
81
- it('should not be able to transfer more than balance', async () => {
82
- await token.grantRole(ownerAddress, 'MINT')
83
- await token.mint(receiverAddress, 100000n)
84
- msg.sender = receiverAddress
85
- assert.throws(() => token.transfer(receiverAddress, otherReceiverAddress, 100001n), {
86
- message: 'amount exceeds balance'
121
+ it('rejects invalid monetary amounts', () => {
122
+ msg.sender = MONETARY_POLICY_AUTHORITY
123
+ assert.throws(() => token.mint(ownerAddress, 0n), { message: 'mint amount must be positive' })
124
+ assert.throws(() => token.mint(ownerAddress, targetSupply), {
125
+ message: 'mint exceeds target supply'
87
126
  })
127
+ assert.throws(() => token.burn(ownerAddress, 0n), { message: 'burn amount must be positive' })
128
+ assert.throws(() => token.burn(ownerAddress, 600_001n), { message: 'amount exceeds balance' })
88
129
  })
89
130
  })