@leofcoin/contracts 0.1.24 → 0.1.26

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.24",
3
+ "version": "0.1.26",
4
4
  "description": "",
5
5
  "type": "module",
6
6
  "exports": {
@@ -8,7 +8,10 @@
8
8
  "./name-service": "./exports/name-service.js",
9
9
  "./name-token": "./exports/name-service.js",
10
10
  "./power-token": "./exports/power-token.js",
11
+ "./mock-token": "./exports/mock-token.js",
11
12
  "./native-token": "./exports/native-token.js",
13
+ "./liquidity-pool": "./exports/liquidity-pool.js",
14
+ "./liquidity-pool-factory": "./exports/liquidity-pool-factory.js",
12
15
  "./validators": "./exports/validators.js"
13
16
  },
14
17
  "scripts": {
@@ -18,15 +21,16 @@
18
21
  },
19
22
  "repository": {
20
23
  "type": "git",
21
- "url": "https://github.com/leofcoin/monorepo"
24
+ "url": "git+https://github.com/leofcoin/monorepo.git",
25
+ "directory": "packages/contracts"
22
26
  },
23
27
  "keywords": [],
24
28
  "author": "",
25
29
  "license": "MIT",
26
30
  "bugs": {
27
- "url": "https://github.com/leofcoin/contracts/issues"
31
+ "url": "https://github.com/leofcoin/monorepo/issues"
28
32
  },
29
- "homepage": "https://github.com/leofcoin/contracts#readme",
33
+ "homepage": "https://github.com/leofcoin/monorepo/tree/main/packages/contracts#readme",
30
34
  "dependencies": {
31
35
  "@leofcoin/standards": "^0.3.7"
32
36
  },
package/rollup.config.js CHANGED
@@ -82,5 +82,53 @@ export default [
82
82
  mangle: false
83
83
  })
84
84
  ]
85
+ },
86
+ {
87
+ input: './src/liquidity-pool.ts',
88
+ output: {
89
+ dir: './exports',
90
+ format: 'es'
91
+ },
92
+ plugins: [
93
+ resolve({
94
+ mainFields: ['exports']
95
+ }),
96
+ typescript(),
97
+ terser({
98
+ mangle: false
99
+ })
100
+ ]
101
+ },
102
+ {
103
+ input: './src/mock-token.ts',
104
+ output: {
105
+ dir: './exports',
106
+ format: 'es'
107
+ },
108
+ plugins: [
109
+ resolve({
110
+ mainFields: ['exports']
111
+ }),
112
+ typescript(),
113
+ terser({
114
+ mangle: false
115
+ })
116
+ ]
117
+ },
118
+ {
119
+ input: './src/liquidity-pool-factory.ts',
120
+ output: {
121
+ dir: './exports',
122
+ format: 'es'
123
+ },
124
+ plugins: [
125
+ resolve({
126
+ mainFields: ['exports']
127
+ }),
128
+ typescript(),
129
+ terser({
130
+ mangle: false
131
+ })
132
+ ]
85
133
  }
86
134
  ]
package/src/factory.ts CHANGED
@@ -55,7 +55,7 @@ export default class Factory extends TokenReceiver {
55
55
  }
56
56
 
57
57
  #isCreator(address: address) {
58
- return msg.staticCall(address, 'creator', [msg.sender]) as unknown as boolean
58
+ return msg.staticCall(address, 'creator') as unknown as boolean
59
59
  }
60
60
 
61
61
  /**
@@ -0,0 +1,72 @@
1
+ import Meta, { MetaState } from '@leofcoin/standards/meta.js'
2
+
3
+ export interface LiquidityPoolFactoryState extends MetaState {
4
+ pools: address[]
5
+ poolsByToken: { [token0: address]: { [token1: address]: address } }
6
+ }
7
+
8
+ const canonicalPair = (tokenA: address, tokenB: address): [address, address] => {
9
+ if (!tokenA || !tokenB) throw new Error('token address undefined')
10
+ if (tokenA === tokenB) throw new Error('pool tokens must differ')
11
+ return [tokenA, tokenB].sort((left, right) => left.localeCompare(right)) as [address, address]
12
+ }
13
+
14
+ export default class LiquidityPoolFactory extends Meta {
15
+ #pools: address[] = []
16
+ #poolsByToken: { [token0: address]: { [token1: address]: address } } = {}
17
+
18
+ constructor(state?: LiquidityPoolFactoryState) {
19
+ super(state)
20
+ if (state) {
21
+ this.#pools = [...state.pools]
22
+ this.#poolsByToken = Object.fromEntries(
23
+ Object.entries(state.poolsByToken).map(([token0, pools]) => [token0, { ...pools }])
24
+ )
25
+ }
26
+ }
27
+
28
+ get state(): LiquidityPoolFactoryState {
29
+ return {
30
+ ...(super.state as MetaState),
31
+ pools: [...this.#pools],
32
+ poolsByToken: Object.fromEntries(
33
+ Object.entries(this.#poolsByToken).map(([token0, pools]) => [token0, { ...pools }])
34
+ )
35
+ }
36
+ }
37
+
38
+ get pools() {
39
+ return [...this.#pools]
40
+ }
41
+
42
+ get totalPools() {
43
+ return BigInt(this.#pools.length)
44
+ }
45
+
46
+ poolFor(tokenA: address, tokenB: address): address | undefined {
47
+ const [token0, token1] = canonicalPair(tokenA, tokenB)
48
+ return this.#poolsByToken[token0]?.[token1]
49
+ }
50
+
51
+ async registerPool(pool: address): Promise<address> {
52
+ if (!pool) throw new Error('pool address undefined')
53
+ if (this.#pools.includes(pool)) throw new Error('pool already registered')
54
+ if (msg.sender !== this.creator) throw new Error('only the factory owner may register pools')
55
+
56
+ const creator = (await msg.staticCall(pool, 'creator')) as address
57
+ if (creator !== msg.sender) throw new Error('factory owner must deploy the pool')
58
+
59
+ const poolToken0 = (await msg.staticCall(pool, 'token0')) as address
60
+ const poolToken1 = (await msg.staticCall(pool, 'token1')) as address
61
+ const [token0, token1] = canonicalPair(poolToken0, poolToken1)
62
+
63
+ const fee = BigInt(await msg.staticCall(pool, 'feeBasisPoints'))
64
+ if (fee < 0n || fee >= 10_000n) throw new Error('invalid pool fee')
65
+ if (this.#poolsByToken[token0]?.[token1]) throw new Error('pair already registered')
66
+
67
+ if (!this.#poolsByToken[token0]) this.#poolsByToken[token0] = {}
68
+ this.#poolsByToken[token0][token1] = pool
69
+ this.#pools.push(pool)
70
+ return pool
71
+ }
72
+ }
@@ -0,0 +1,259 @@
1
+ import Meta, { MetaState } from '@leofcoin/standards/meta.js'
2
+
3
+ export interface LiquidityPoolState extends MetaState {
4
+ token0: address
5
+ token1: address
6
+ feeBasisPoints: bigint
7
+ reserve0: bigint
8
+ reserve1: bigint
9
+ totalLiquidity: bigint
10
+ liquidity: { [provider: address]: bigint }
11
+ }
12
+
13
+ const BASIS_POINTS = 10_000n
14
+
15
+ const squareRoot = (value: bigint): bigint => {
16
+ if (value < 0n) throw new Error('cannot calculate square root of a negative value')
17
+ if (value < 2n) return value
18
+
19
+ let current = value
20
+ let next = (current + value / current) / 2n
21
+ while (next < current) {
22
+ current = next
23
+ next = (current + value / current) / 2n
24
+ }
25
+ return current
26
+ }
27
+
28
+ export default class LiquidityPool extends Meta {
29
+ #token0: address
30
+ #token1: address
31
+ #feeBasisPoints: bigint
32
+ #reserve0 = 0n
33
+ #reserve1 = 0n
34
+ #totalLiquidity = 0n
35
+ #liquidity: { [provider: address]: bigint } = {}
36
+ #locked = false
37
+
38
+ constructor(
39
+ token0: address,
40
+ token1: address,
41
+ feeBasisPoints: bigint = 30n,
42
+ state?: LiquidityPoolState
43
+ ) {
44
+ if (!token0 || !token1) throw new Error('token address undefined')
45
+ if (token0 === token1) throw new Error('pool tokens must differ')
46
+ super(state)
47
+ this.#token0 = token0
48
+ this.#token1 = token1
49
+ this.#feeBasisPoints = BigInt(state?.feeBasisPoints ?? feeBasisPoints)
50
+ if (this.#feeBasisPoints < 0n || this.#feeBasisPoints >= BASIS_POINTS)
51
+ throw new Error('invalid fee')
52
+
53
+ if (state) {
54
+ if (state.token0 !== token0 || state.token1 !== token1)
55
+ throw new Error('pool state token mismatch')
56
+ this.#reserve0 = BigInt(state.reserve0)
57
+ this.#reserve1 = BigInt(state.reserve1)
58
+ this.#totalLiquidity = BigInt(state.totalLiquidity)
59
+ this.#liquidity = Object.fromEntries(
60
+ Object.entries(state.liquidity).map(([provider, amount]) => [provider, BigInt(amount)])
61
+ )
62
+ }
63
+ }
64
+
65
+ get token0() {
66
+ return this.#token0
67
+ }
68
+
69
+ get token1() {
70
+ return this.#token1
71
+ }
72
+
73
+ get feeBasisPoints() {
74
+ return this.#feeBasisPoints
75
+ }
76
+
77
+ get reserve0() {
78
+ return this.#reserve0
79
+ }
80
+
81
+ get reserve1() {
82
+ return this.#reserve1
83
+ }
84
+
85
+ get reserves() {
86
+ return [this.#reserve0, this.#reserve1]
87
+ }
88
+
89
+ get totalLiquidity() {
90
+ return this.#totalLiquidity
91
+ }
92
+
93
+ get state(): LiquidityPoolState {
94
+ return {
95
+ ...(super.state as MetaState),
96
+ token0: this.#token0,
97
+ token1: this.#token1,
98
+ feeBasisPoints: this.#feeBasisPoints,
99
+ reserve0: this.#reserve0,
100
+ reserve1: this.#reserve1,
101
+ totalLiquidity: this.#totalLiquidity,
102
+ liquidity: { ...this.#liquidity }
103
+ }
104
+ }
105
+
106
+ liquidityOf(provider: address): bigint {
107
+ return this.#liquidity[provider] ?? 0n
108
+ }
109
+
110
+ getAmountOut(tokenIn: address, amountIn: bigint): bigint {
111
+ amountIn = BigInt(amountIn)
112
+ if (amountIn <= 0n) throw new Error('input amount must be positive')
113
+
114
+ let reserveIn: bigint
115
+ let reserveOut: bigint
116
+ if (tokenIn === this.#token0) {
117
+ reserveIn = this.#reserve0
118
+ reserveOut = this.#reserve1
119
+ } else if (tokenIn === this.#token1) {
120
+ reserveIn = this.#reserve1
121
+ reserveOut = this.#reserve0
122
+ } else {
123
+ throw new Error('token not in pool')
124
+ }
125
+ if (reserveIn <= 0n || reserveOut <= 0n) throw new Error('insufficient liquidity')
126
+
127
+ const amountInWithFee = amountIn * (BASIS_POINTS - this.#feeBasisPoints)
128
+ return (amountInWithFee * reserveOut) / (reserveIn * BASIS_POINTS + amountInWithFee)
129
+ }
130
+
131
+ async addLiquidity(
132
+ amount0Desired: bigint,
133
+ amount1Desired: bigint,
134
+ minimumLiquidity: bigint = 0n
135
+ ) {
136
+ return this.#withLock(async () => {
137
+ amount0Desired = BigInt(amount0Desired)
138
+ amount1Desired = BigInt(amount1Desired)
139
+ minimumLiquidity = BigInt(minimumLiquidity)
140
+ if (amount0Desired <= 0n || amount1Desired <= 0n)
141
+ throw new Error('liquidity amounts must be positive')
142
+ if (minimumLiquidity < 0n) throw new Error('minimum liquidity cannot be negative')
143
+
144
+ let amount0 = amount0Desired
145
+ let amount1 = amount1Desired
146
+ let minted: bigint
147
+ if (this.#totalLiquidity === 0n) {
148
+ minted = squareRoot(amount0 * amount1)
149
+ } else {
150
+ const amount1ForAmount0 = (amount0Desired * this.#reserve1) / this.#reserve0
151
+ if (amount1ForAmount0 <= amount1Desired) {
152
+ amount1 = amount1ForAmount0
153
+ } else {
154
+ amount0 = (amount1Desired * this.#reserve0) / this.#reserve1
155
+ }
156
+ minted = (amount0 * this.#totalLiquidity) / this.#reserve0
157
+ }
158
+
159
+ if (amount0 <= 0n || amount1 <= 0n || minted <= 0n)
160
+ throw new Error('liquidity amount rounds to zero')
161
+ if (minted < minimumLiquidity) throw new Error('minimum liquidity not met')
162
+
163
+ const provider = msg.sender
164
+ this.#reserve0 += amount0
165
+ this.#reserve1 += amount1
166
+ this.#totalLiquidity += minted
167
+ this.#liquidity[provider] = this.liquidityOf(provider) + minted
168
+
169
+ await this.#pull(this.#token0, provider, amount0)
170
+ await this.#pull(this.#token1, provider, amount1)
171
+ return { amount0, amount1, liquidity: minted }
172
+ })
173
+ }
174
+
175
+ async removeLiquidity(
176
+ liquidity: bigint,
177
+ minimumAmount0: bigint = 0n,
178
+ minimumAmount1: bigint = 0n,
179
+ receiver: address = msg.sender
180
+ ) {
181
+ return this.#withLock(async () => {
182
+ liquidity = BigInt(liquidity)
183
+ minimumAmount0 = BigInt(minimumAmount0)
184
+ minimumAmount1 = BigInt(minimumAmount1)
185
+ if (!receiver) throw new Error('receiver undefined')
186
+ if (liquidity <= 0n) throw new Error('liquidity must be positive')
187
+ if (this.liquidityOf(msg.sender) < liquidity)
188
+ throw new Error('insufficient liquidity balance')
189
+
190
+ const amount0 = (liquidity * this.#reserve0) / this.#totalLiquidity
191
+ const amount1 = (liquidity * this.#reserve1) / this.#totalLiquidity
192
+ if (amount0 <= 0n || amount1 <= 0n) throw new Error('withdrawal amount rounds to zero')
193
+ if (amount0 < minimumAmount0 || amount1 < minimumAmount1)
194
+ throw new Error('minimum withdrawal amount not met')
195
+
196
+ this.#liquidity[msg.sender] -= liquidity
197
+ this.#totalLiquidity -= liquidity
198
+ this.#reserve0 -= amount0
199
+ this.#reserve1 -= amount1
200
+
201
+ await this.#push(this.#token0, receiver, amount0)
202
+ await this.#push(this.#token1, receiver, amount1)
203
+ return { amount0, amount1, liquidity }
204
+ })
205
+ }
206
+
207
+ async swapExactTokensForTokens(
208
+ tokenIn: address,
209
+ amountIn: bigint,
210
+ minimumAmountOut: bigint,
211
+ receiver: address = msg.sender,
212
+ deadline: number = Number.MAX_SAFE_INTEGER
213
+ ) {
214
+ return this.#withLock(async () => {
215
+ amountIn = BigInt(amountIn)
216
+ minimumAmountOut = BigInt(minimumAmountOut)
217
+ if (!receiver) throw new Error('receiver undefined')
218
+ if (!Number.isSafeInteger(deadline) || deadline < Date.now()) throw new Error('swap expired')
219
+
220
+ const amountOut = this.getAmountOut(tokenIn, amountIn)
221
+ if (amountOut <= 0n) throw new Error('output amount rounds to zero')
222
+ if (amountOut < minimumAmountOut) throw new Error('minimum output amount not met')
223
+
224
+ const sender = msg.sender
225
+ if (tokenIn === this.#token0) {
226
+ this.#reserve0 += amountIn
227
+ this.#reserve1 -= amountOut
228
+ await this.#pull(this.#token0, sender, amountIn)
229
+ await this.#push(this.#token1, receiver, amountOut)
230
+ } else if (tokenIn === this.#token1) {
231
+ this.#reserve1 += amountIn
232
+ this.#reserve0 -= amountOut
233
+ await this.#pull(this.#token1, sender, amountIn)
234
+ await this.#push(this.#token0, receiver, amountOut)
235
+ } else {
236
+ throw new Error('token not in pool')
237
+ }
238
+ return amountOut
239
+ })
240
+ }
241
+
242
+ async #pull(token: address, owner: address, amount: bigint) {
243
+ return msg.call(token, 'transferFrom', [owner, msg.contract, amount])
244
+ }
245
+
246
+ async #push(token: address, receiver: address, amount: bigint) {
247
+ return msg.call(token, 'transfer', [receiver, amount])
248
+ }
249
+
250
+ async #withLock<T>(operation: () => Promise<T>): Promise<T> {
251
+ if (this.#locked) throw new Error('reentrant call')
252
+ this.#locked = true
253
+ try {
254
+ return await operation()
255
+ } finally {
256
+ this.#locked = false
257
+ }
258
+ }
259
+ }
@@ -0,0 +1,114 @@
1
+ import Token, { TokenState } from '@leofcoin/standards/token.js'
2
+
3
+ export interface MockTokenAllocation {
4
+ address: address
5
+ amount: bigint | string
6
+ }
7
+
8
+ export interface MockTokenState extends TokenState {
9
+ name: string
10
+ symbol: string
11
+ decimals: number
12
+ }
13
+
14
+ const validateMetadata = (name: string, symbol: string, decimals: number) => {
15
+ if (!name?.trim()) throw new Error('token name is required')
16
+ if (!symbol?.trim()) throw new Error('token symbol is required')
17
+ if (!Number.isSafeInteger(decimals) || decimals < 0 || decimals > 18)
18
+ throw new Error('token decimals must be an integer between 0 and 18')
19
+ }
20
+
21
+ const initialState = (
22
+ name: string,
23
+ symbol: string,
24
+ decimals: number,
25
+ initialSupply: bigint,
26
+ allocations: MockTokenAllocation[]
27
+ ): MockTokenState => {
28
+ initialSupply = BigInt(initialSupply)
29
+ if (initialSupply <= 0n) throw new Error('initial supply must be positive')
30
+
31
+ const effectiveAllocations = allocations.length > 0 ? allocations : [{ address: msg.sender, amount: initialSupply }]
32
+ const balances: Record<address, bigint> = {}
33
+ let allocated = 0n
34
+
35
+ for (const allocation of [...effectiveAllocations].sort((left, right) => left.address.localeCompare(right.address))) {
36
+ if (!allocation.address || balances[allocation.address] !== undefined)
37
+ throw new Error('invalid or duplicate allocation address')
38
+ const amount = BigInt(allocation.amount)
39
+ if (amount <= 0n) throw new Error('allocation amount must be positive')
40
+ balances[allocation.address] = amount
41
+ allocated += amount
42
+ }
43
+
44
+ if (allocated !== initialSupply) {
45
+ throw new Error(`allocations must equal initial supply: expected ${initialSupply}, got ${allocated}`)
46
+ }
47
+
48
+ return {
49
+ name,
50
+ symbol,
51
+ decimals,
52
+ creator: msg.sender,
53
+ createdAt: BigInt(Date.now()),
54
+ roles: {
55
+ OWNER: [msg.sender],
56
+ MINT: [msg.sender],
57
+ BURN: [msg.sender]
58
+ },
59
+ holders: BigInt(Object.keys(balances).length),
60
+ balances,
61
+ approvals: {},
62
+ totalSupply: initialSupply
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Development-only fungible asset used to test generic token and DEX behavior.
68
+ * A deployment of this contract never represents or escrows the external asset
69
+ * whose name or symbol it mimics.
70
+ */
71
+ export default class MockToken extends Token {
72
+ #tokenName: string
73
+ #tokenSymbol: string
74
+ #tokenDecimals: number
75
+
76
+ constructor(
77
+ name: string,
78
+ symbol: string,
79
+ decimals: number,
80
+ initialSupply: bigint,
81
+ allocations: MockTokenAllocation[] = [],
82
+ restoredState?: MockTokenState
83
+ ) {
84
+ validateMetadata(name, symbol, decimals)
85
+ const state = restoredState || initialState(name, symbol, decimals, initialSupply, allocations)
86
+ if (state.name !== name || state.symbol !== symbol || state.decimals !== decimals)
87
+ throw new Error('restored token metadata mismatch')
88
+ super(name, symbol, decimals, state)
89
+ this.#tokenName = name
90
+ this.#tokenSymbol = symbol
91
+ this.#tokenDecimals = decimals
92
+ }
93
+
94
+ get name() {
95
+ return this.#tokenName
96
+ }
97
+
98
+ get symbol() {
99
+ return this.#tokenSymbol
100
+ }
101
+
102
+ get decimals() {
103
+ return this.#tokenDecimals
104
+ }
105
+
106
+ get state(): MockTokenState {
107
+ return {
108
+ ...super.state,
109
+ name: this.#tokenName,
110
+ symbol: this.#tokenSymbol,
111
+ decimals: this.#tokenDecimals
112
+ } as MockTokenState
113
+ }
114
+ }
package/src/validators.ts CHANGED
@@ -126,7 +126,7 @@ export default class Validators extends Roles {
126
126
  if (this.minimumBalance > balance)
127
127
  throw new Error(`balance to low! got: ${balance} need: ${this.#minimumBalance}`)
128
128
 
129
- await msg.call(this.currency, 'transfer', [validator, msg.contract, this.#minimumBalance])
129
+ await msg.call(this.currency, 'transferFrom', [validator, msg.contract, this.#minimumBalance])
130
130
 
131
131
  this.#balances[validator] = this.#minimumBalance
132
132
  this.#validators.push(validator)
@@ -135,7 +135,7 @@ export default class Validators extends Roles {
135
135
  async removeValidator(validator) {
136
136
  this.#isAllowed(validator)
137
137
  if (!this.has(validator)) throw new Error('validator not found')
138
- await msg.call(this.currency, 'transfer', [msg.contract, validator, this.#minimumBalance])
138
+ await msg.call(this.currency, 'transfer', [validator, this.#minimumBalance])
139
139
  delete this.#balances[validator]
140
140
  this.#validators.splice(this.#validators.indexOf(validator), 1)
141
141
  }
package/test/factory.js CHANGED
@@ -40,10 +40,11 @@ describe('Factory', () => {
40
40
  return BigInt(100) // Balance too low
41
41
  } else if (method === 'creator') {
42
42
  return true
43
- } else if (method === 'transfer') {
44
- throw new Error('amount exceeds balance')
45
43
  }
46
44
  }
45
+ global.msg.call = async (currency, method) => {
46
+ if (method === 'transferFrom') throw new Error('amount exceeds balance')
47
+ }
47
48
  await assert.rejects(async () => await factory.registerContract('0xContractAddress'), {
48
49
  message: 'amount exceeds balance'
49
50
  })
@@ -57,9 +58,16 @@ describe('Factory', () => {
57
58
  })
58
59
 
59
60
  it('should successfully register a contract', async () => {
61
+ let creatorParameters
62
+ global.msg.staticCall = async (currency, method, args) => {
63
+ if (method === 'creator') creatorParameters = args
64
+ if (method === 'balanceOf') return BigInt(100000)
65
+ return true
66
+ }
60
67
  await factory.registerContract('0xContractAddress')
61
68
 
62
69
  assert.equal(factory.totalContracts, BigInt(1))
63
70
  assert.ok(factory.contracts.includes('0xContractAddress'))
71
+ assert.equal(creatorParameters, undefined)
64
72
  })
65
73
  })
@@ -0,0 +1,71 @@
1
+ import { beforeEach, describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import LiquidityPoolFactory from '../exports/liquidity-pool-factory.js'
4
+
5
+ describe('LiquidityPoolFactory', () => {
6
+ const creator = '0xCreator'
7
+ const token0 = '0xToken0'
8
+ const token1 = '0xToken1'
9
+ const poolAddress = '0xPool'
10
+ let factory
11
+ let pool
12
+
13
+ beforeEach(() => {
14
+ pool = { creator, token0, token1, feeBasisPoints: 30n }
15
+ global.msg = {
16
+ sender: creator,
17
+ contract: '0xFactory',
18
+ staticCall: async (target, method) => {
19
+ assert.equal(target, poolAddress)
20
+ return pool[method]
21
+ }
22
+ }
23
+ factory = new LiquidityPoolFactory()
24
+ })
25
+
26
+ it('registers one canonical pool per pair', async () => {
27
+ assert.equal(await factory.registerPool(poolAddress), poolAddress)
28
+ assert.equal(factory.poolFor(token0, token1), poolAddress)
29
+ assert.equal(factory.poolFor(token1, token0), poolAddress)
30
+ assert.deepEqual(factory.pools, [poolAddress])
31
+ assert.equal(factory.totalPools, 1n)
32
+
33
+ await assert.rejects(factory.registerPool(poolAddress), {
34
+ message: 'pool already registered'
35
+ })
36
+ })
37
+
38
+ it('requires the factory owner and accepts either pool token order', async () => {
39
+ global.msg.sender = '0xAttacker'
40
+ await assert.rejects(factory.registerPool(poolAddress), {
41
+ message: 'only the factory owner may register pools'
42
+ })
43
+
44
+ global.msg.sender = creator
45
+ pool = { ...pool, token0: token1, token1: token0 }
46
+ assert.equal(await factory.registerPool(poolAddress), poolAddress)
47
+ assert.equal(factory.poolFor(token0, token1), poolAddress)
48
+ })
49
+
50
+ it('requires pools to be deployed by the factory owner', async () => {
51
+ pool.creator = '0xDifferentCreator'
52
+ await assert.rejects(factory.registerPool(poolAddress), {
53
+ message: 'factory owner must deploy the pool'
54
+ })
55
+ })
56
+
57
+ it('rejects a second pool for the same pair and restores state', async () => {
58
+ await factory.registerPool(poolAddress)
59
+ const restored = new LiquidityPoolFactory(factory.state)
60
+ assert.equal(restored.poolFor(token1, token0), poolAddress)
61
+
62
+ const secondPool = '0xSecondPool'
63
+ global.msg.staticCall = async (target, method) => {
64
+ assert.equal(target, secondPool)
65
+ return pool[method]
66
+ }
67
+ await assert.rejects(restored.registerPool(secondPool), {
68
+ message: 'pair already registered'
69
+ })
70
+ })
71
+ })
@@ -0,0 +1,175 @@
1
+ import { beforeEach, describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import LiquidityPool from '../exports/liquidity-pool.js'
4
+ import MockToken from '../exports/mock-token.js'
5
+
6
+ describe('LiquidityPool', () => {
7
+ const poolAddress = '0xPool'
8
+ const token0 = '0xToken0'
9
+ const token1 = '0xToken1'
10
+ const alice = '0xAlice'
11
+ const bob = '0xBob'
12
+ let pool
13
+ let balances
14
+ let allowances
15
+
16
+ const balanceOf = (token, account) => balances[token][account] ?? 0n
17
+ const allowanceOf = (token, owner, operator) => allowances[token][owner]?.[operator] ?? 0n
18
+
19
+ beforeEach(() => {
20
+ balances = {
21
+ [token0]: { [alice]: 100_000n, [bob]: 100_000n },
22
+ [token1]: { [alice]: 100_000n, [bob]: 100_000n }
23
+ }
24
+ allowances = {
25
+ [token0]: {
26
+ [alice]: { [poolAddress]: 100_000n },
27
+ [bob]: { [poolAddress]: 100_000n }
28
+ },
29
+ [token1]: {
30
+ [alice]: { [poolAddress]: 100_000n },
31
+ [bob]: { [poolAddress]: 100_000n }
32
+ }
33
+ }
34
+ global.msg = {
35
+ sender: alice,
36
+ contract: poolAddress,
37
+ call: async (contract, method, params) => {
38
+ if (method === 'transferFrom') {
39
+ const [owner, receiver, amount] = params
40
+ const approved = allowanceOf(contract, owner, poolAddress)
41
+ if (approved < amount) throw new Error('amount exceeds allowance')
42
+ if (balanceOf(contract, owner) < amount) throw new Error('amount exceeds balance')
43
+ allowances[contract][owner][poolAddress] = approved - amount
44
+ balances[contract][owner] -= amount
45
+ balances[contract][receiver] = balanceOf(contract, receiver) + amount
46
+ return
47
+ }
48
+ if (method === 'transfer') {
49
+ const [receiver, amount] = params
50
+ if (balanceOf(contract, poolAddress) < amount) throw new Error('amount exceeds balance')
51
+ balances[contract][poolAddress] -= amount
52
+ balances[contract][receiver] = balanceOf(contract, receiver) + amount
53
+ return
54
+ }
55
+ throw new Error(`unsupported token method: ${method}`)
56
+ }
57
+ }
58
+ pool = new LiquidityPool(token0, token1)
59
+ })
60
+
61
+ it('adds balanced initial liquidity using approvals', async () => {
62
+ const result = await pool.addLiquidity(10_000n, 10_000n, 10_000n)
63
+
64
+ assert.deepEqual(result, {
65
+ amount0: 10_000n,
66
+ amount1: 10_000n,
67
+ liquidity: 10_000n
68
+ })
69
+ assert.deepEqual(pool.reserves, [10_000n, 10_000n])
70
+ assert.equal(pool.liquidityOf(alice), 10_000n)
71
+ assert.equal(balanceOf(token0, poolAddress), 10_000n)
72
+ assert.equal(balanceOf(token1, poolAddress), 10_000n)
73
+ })
74
+
75
+ it('accepts only the proportional part of later liquidity', async () => {
76
+ await pool.addLiquidity(10_000n, 20_000n)
77
+ global.msg.sender = bob
78
+
79
+ const result = await pool.addLiquidity(10_000n, 30_000n)
80
+
81
+ assert.deepEqual(result, {
82
+ amount0: 10_000n,
83
+ amount1: 20_000n,
84
+ liquidity: 14_142n
85
+ })
86
+ assert.deepEqual(pool.reserves, [20_000n, 40_000n])
87
+ assert.equal(balanceOf(token1, bob), 80_000n)
88
+ })
89
+
90
+ it('swaps with a constant-product fee and enforces slippage', async () => {
91
+ await pool.addLiquidity(10_000n, 10_000n)
92
+ global.msg.sender = bob
93
+
94
+ const quoted = pool.getAmountOut(token0, 1_000n)
95
+ assert.equal(quoted, 906n)
96
+ const productBefore = pool.reserve0 * pool.reserve1
97
+ await assert.rejects(pool.swapExactTokensForTokens(token0, 1_000n, 907n, bob, Date.now()), {
98
+ message: 'minimum output amount not met'
99
+ })
100
+
101
+ const received = await pool.swapExactTokensForTokens(token0, 1_000n, 906n, bob, Date.now())
102
+ assert.equal(received, 906n)
103
+ assert.deepEqual(pool.reserves, [11_000n, 9_094n])
104
+ assert.ok(pool.reserve0 * pool.reserve1 > productBefore)
105
+ assert.equal(balanceOf(token1, bob), 100_906n)
106
+ })
107
+
108
+ it('rejects expired swaps', async () => {
109
+ await pool.addLiquidity(10_000n, 10_000n)
110
+ await assert.rejects(pool.swapExactTokensForTokens(token0, 1_000n, 1n, alice, Date.now() - 1), {
111
+ message: 'swap expired'
112
+ })
113
+ })
114
+
115
+ it('returns the provider share and can restore its state', async () => {
116
+ await pool.addLiquidity(10_000n, 20_000n)
117
+ const restored = new LiquidityPool(token0, token1, 30n, pool.state)
118
+
119
+ assert.deepEqual(restored.reserves, [10_000n, 20_000n])
120
+ assert.equal(restored.liquidityOf(alice), 14_142n)
121
+
122
+ const result = await pool.removeLiquidity(14_142n, 9_999n, 19_999n)
123
+ assert.deepEqual(result, {
124
+ amount0: 10_000n,
125
+ amount1: 20_000n,
126
+ liquidity: 14_142n
127
+ })
128
+ assert.deepEqual(pool.reserves, [0n, 0n])
129
+ assert.equal(pool.totalLiquidity, 0n)
130
+ })
131
+
132
+ it('rejects invalid pairs and missing allowance', async () => {
133
+ assert.throws(() => new LiquidityPool(token0, token0), {
134
+ message: 'pool tokens must differ'
135
+ })
136
+ allowances[token0][alice][poolAddress] = 0n
137
+ await assert.rejects(pool.addLiquidity(10_000n, 10_000n), {
138
+ message: 'amount exceeds allowance'
139
+ })
140
+ })
141
+
142
+ it('trades mock WETH and USDC through their real token contract interface', async () => {
143
+ const wethAddress = '0xMockWETH'
144
+ const usdcAddress = '0xMockUSDC'
145
+ global.msg = { sender: alice }
146
+ const weth = new MockToken('Mock Wrapped Ether', 'WETH', 18, 100_000n)
147
+ const usdc = new MockToken('Mock USD Coin', 'USDC', 6, 200_000n)
148
+ const tokens = { [wethAddress]: weth, [usdcAddress]: usdc }
149
+ const contractPool = new LiquidityPool(wethAddress, usdcAddress)
150
+
151
+ weth.approve(poolAddress, 100_000n)
152
+ usdc.approve(poolAddress, 200_000n)
153
+ global.msg = {
154
+ sender: alice,
155
+ contract: poolAddress,
156
+ call: async (contract, method, params) => {
157
+ const previousMessage = global.msg
158
+ global.msg = { sender: poolAddress, contract }
159
+ try {
160
+ return await tokens[contract][method](...params)
161
+ } finally {
162
+ global.msg = previousMessage
163
+ }
164
+ }
165
+ }
166
+
167
+ await contractPool.addLiquidity(10_000n, 20_000n)
168
+
169
+ assert.deepEqual(contractPool.reserves, [10_000n, 20_000n])
170
+ assert.equal(weth.balanceOf(poolAddress), 10_000n)
171
+ assert.equal(usdc.balanceOf(poolAddress), 20_000n)
172
+ assert.equal(weth.allowance(alice, poolAddress), 90_000n)
173
+ assert.equal(usdc.allowance(alice, poolAddress), 180_000n)
174
+ })
175
+ })
@@ -0,0 +1,48 @@
1
+ import { beforeEach, describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import MockToken from '../exports/mock-token.js'
4
+
5
+ describe('MockToken', () => {
6
+ const owner = '0xOwner'
7
+ const alice = '0xAlice'
8
+
9
+ beforeEach(() => {
10
+ global.msg = { sender: owner }
11
+ })
12
+
13
+ it('creates mock WETH, WBNB and USDC with their conventional decimals', () => {
14
+ const assets = [
15
+ new MockToken('Mock Wrapped Ether', 'WETH', 18, 1_000_000n),
16
+ new MockToken('Mock Wrapped BNB', 'WBNB', 18, 2_000_000n),
17
+ new MockToken('Mock USD Coin', 'USDC', 6, 3_000_000n)
18
+ ]
19
+
20
+ assert.deepEqual(
21
+ assets.map((asset) => [asset.symbol, asset.decimals, asset.balanceOf(owner)]),
22
+ [
23
+ ['WETH', 18, 1_000_000n],
24
+ ['WBNB', 18, 2_000_000n],
25
+ ['USDC', 6, 3_000_000n]
26
+ ]
27
+ )
28
+ })
29
+
30
+ it('supports transfers, allowances and deterministic state restoration', () => {
31
+ const token = new MockToken('Mock USD Coin', 'USDC', 6, 1_000_000n)
32
+ token.transfer(alice, 100_000n)
33
+ token.approve(alice, 25_000n)
34
+
35
+ const restored = new MockToken('Mock USD Coin', 'USDC', 6, 1_000_000n, [], token.state)
36
+
37
+ assert.equal(restored.balanceOf(owner), 900_000n)
38
+ assert.equal(restored.balanceOf(alice), 100_000n)
39
+ assert.equal(restored.allowance(owner, alice), 25_000n)
40
+ assert.equal(restored.totalSupply, 1_000_000n)
41
+ })
42
+
43
+ it('rejects allocations that do not match the declared supply', () => {
44
+ assert.throws(() => new MockToken('Mock USD Coin', 'USDC', 6, 1_000_000n, [{ address: owner, amount: 1n }]), {
45
+ message: 'allocations must equal initial supply: expected 1000000, got 1'
46
+ })
47
+ })
48
+ })
@@ -89,7 +89,7 @@ describe('Leofcoin', () => {
89
89
 
90
90
  it('allows allocated holders to transfer', () => {
91
91
  msg.sender = receiverAddress
92
- token.transfer(receiverAddress, otherReceiverAddress, 10_000n)
92
+ token.transfer(otherReceiverAddress, 10_000n)
93
93
  assert.equal(token.balanceOf(receiverAddress), 390_000n)
94
94
  assert.equal(token.balanceOf(otherReceiverAddress), 10_000n)
95
95
  })