Skip to content
This repository was archived by the owner on Sep 20, 2023. It is now read-only.

Commit f2baf55

Browse files
author
Tom Linton
authored
T3 Lockup Implementation (#3772)
* Start lockup implementation * Restructure and implement balance changes to handle lockups * Logic for earned tokens * Tests passing * Test updates * Refactor logic * Fix data types * Restructure logic * Below 0 handling and tests * Prettier * QRcode package * Yarn.lock conflict
1 parent 2bf7e45 commit f2baf55

21 files changed

+810
-159
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
const path = require('path')
2+
3+
module.exports = {
4+
'models-path': path.resolve('src', 'models'),
5+
'migrations-path': path.resolve('.', 'migrations')
6+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
'use strict'
2+
3+
const tableName = 't3_lockup'
4+
5+
module.exports = {
6+
up: (queryInterface, Sequelize) => {
7+
return queryInterface.createTable(tableName, {
8+
id: {
9+
allowNull: false,
10+
autoIncrement: true,
11+
primaryKey: true,
12+
type: Sequelize.INTEGER
13+
},
14+
user_id: {
15+
allowNull: false,
16+
type: Sequelize.INTEGER,
17+
references: { model: 't3_user', key: 'id' }
18+
},
19+
start: {
20+
allowNull: false,
21+
type: Sequelize.DATE
22+
},
23+
end: {
24+
allowNull: false,
25+
type: Sequelize.DATE
26+
},
27+
bonus_rate: {
28+
allowNull: false,
29+
type: Sequelize.FLOAT
30+
},
31+
amount: {
32+
type: Sequelize.DECIMAL
33+
},
34+
created_at: {
35+
allowNull: false,
36+
type: Sequelize.DATE
37+
},
38+
updated_at: {
39+
allowNull: false,
40+
type: Sequelize.DATE
41+
}
42+
})
43+
},
44+
down: queryInterface => {
45+
return queryInterface.dropTable(tableName)
46+
}
47+
}

infra/token-transfer-server/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
"passport-totp": "^0.0.2",
5151
"per-env": "^1.0.2",
5252
"pg": "^7.11.0",
53+
"qrcode": "^1.4.2",
5354
"react-redux": "^7.1.0",
5455
"react-router-dom": "^5.0.1",
5556
"react-thunk": "^1.0.0",

infra/token-transfer-server/src/config.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,17 @@ const unlockDate = process.env.UNLOCK_DATE
4040
? moment.utc(process.env.UNLOCK_DATE)
4141
: moment.utc('2020-01-01')
4242

43+
// Lockup bonus rate as a percentage
44+
const lockupBonusRate = process.env.LOCKUP_BONUS_RATE || 10
45+
46+
// Lockup duration in months
47+
const lockupDuration = process.env.LOCKUP_DURATION || 12
48+
4349
module.exports = {
4450
discordWebhookUrl,
4551
encryptionSecret,
52+
lockupBonusRate,
53+
lockupDuration,
4654
networkId,
4755
port,
4856
portalUrl,

infra/token-transfer-server/src/controllers/account.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@ const express = require('express')
22
const router = express.Router()
33
const { check, validationResult } = require('express-validator')
44

5+
const { asyncMiddleware } = require('../utils')
56
const { ensureLoggedIn } = require('../lib/login')
67
const {
7-
asyncMiddleware,
88
isEthereumAddress,
99
isExistingAddress,
1010
isExistingNickname
11-
} = require('../utils')
11+
} = require('../validators')
1212
const { Account } = require('../models')
1313

1414
/**

infra/token-transfer-server/src/controllers/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const router = express.Router()
44
router.use('/api', require('./account'))
55
router.use('/api', require('./event'))
66
router.use('/api', require('./grant'))
7+
router.use('/api', require('./lockup'))
78
router.use('/api', require('./login'))
89
router.use('/api', require('./transfer'))
910
router.use('/api', require('./user'))
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
const express = require('express')
2+
const router = express.Router()
3+
const AsyncLock = require('async-lock')
4+
const lock = new AsyncLock()
5+
const { check, validationResult } = require('express-validator')
6+
7+
const { asyncMiddleware } = require('../utils')
8+
const { isValidTotp } = require('../validators')
9+
const { ensureLoggedIn } = require('../lib/login')
10+
const { Lockup } = require('../models')
11+
const { addLockup } = require('../lib/lockup')
12+
const logger = require('../logger')
13+
14+
/**
15+
* Returns lockups for the authenticated user.
16+
*/
17+
router.get(
18+
'/lockups',
19+
ensureLoggedIn,
20+
asyncMiddleware(async (req, res) => {
21+
const lockups = await Lockup.findAll({ where: { userId: req.user.id } })
22+
res.json(
23+
lockups.map(lockup => {
24+
return lockup.get({ plain: true })
25+
})
26+
)
27+
})
28+
)
29+
30+
/**
31+
* Add a new lockup.
32+
*/
33+
router.post(
34+
'/lockups',
35+
[
36+
check('amount')
37+
.isNumeric()
38+
.toInt()
39+
.isInt({ min: 0 })
40+
.withMessage('Amount must be greater than 0'),
41+
check('code').custom(isValidTotp),
42+
ensureLoggedIn
43+
],
44+
asyncMiddleware(async (req, res) => {
45+
const errors = validationResult(req)
46+
if (!errors.isEmpty()) {
47+
return res
48+
.status(422)
49+
.json({ errors: errors.array({ onlyFirstError: true }) })
50+
}
51+
52+
const { amount } = req.body
53+
54+
let lockup
55+
try {
56+
await lock.acquire(req.user.id, async () => {
57+
lockup = await addLockup(req.user.id, amount)
58+
})
59+
logger.info(`User ${req.user.email} added a lockup of ${amount} OGN`)
60+
} catch (e) {
61+
if (e instanceof ReferenceError || e instanceof RangeError) {
62+
res.status(422).send(e.message)
63+
} else {
64+
throw e
65+
}
66+
}
67+
res.status(201).json(lockup.get({ plain: true }))
68+
})
69+
)
70+
71+
module.exports = router

infra/token-transfer-server/src/controllers/login.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ router.post(
2424
logger.debug('/send_email_code called for', email)
2525

2626
// No await to prevent enumeration of valid emails
27-
sendLoginToken(email)
27+
if (process.env.NODE_ENV !== 'test') {
28+
sendLoginToken(email)
29+
}
2830

2931
res.setHeader('Content-Type', 'application/json')
3032
res.send(JSON.stringify({ email }))

infra/token-transfer-server/src/controllers/transfer.js

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,10 @@ const { Transfer } = require('../../src/models')
1111
const { ensureLoggedIn } = require('../lib/login')
1212
const {
1313
asyncMiddleware,
14-
isEthereumAddress,
15-
isValidTotp,
1614
getFingerprintData,
17-
getUnlockDate,
18-
hasBalance
15+
getUnlockDate
1916
} = require('../utils')
17+
const { isEthereumAddress, isValidTotp } = require('../validators')
2018
const { encryptionSecret, unlockDate } = require('../config')
2119
const { addTransfer, confirmTransfer } = require('../lib/transfer')
2220

@@ -44,7 +42,8 @@ router.post(
4442
check('amount')
4543
.isNumeric()
4644
.toInt()
47-
.custom(hasBalance),
45+
.isInt({ min: 0 })
46+
.withMessage('Amount must be greater than 0'),
4847
check('address').custom(isEthereumAddress),
4948
check('code').custom(isValidTotp),
5049
ensureLoggedIn
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
const { Grant, Lockup, Transfer, User } = require('../models')
2+
const {
3+
calculateVested,
4+
calculateUnlockedEarnings,
5+
calculateWithdrawn,
6+
calculateLocked,
7+
calculateEarnings
8+
} = require('../shared')
9+
const logger = require('../logger')
10+
11+
/**
12+
* Helper method to check if a user has balance available for adding a transfer
13+
* or a lockup.
14+
*
15+
* Throws an exception in case the request is invalid.
16+
*
17+
* @param userId
18+
* @param amount
19+
* @returns Promise<User>
20+
* @private
21+
*/
22+
async function hasBalance(userId, amount) {
23+
const user = await User.findOne({
24+
where: {
25+
id: userId
26+
},
27+
include: [{ model: Grant }, { model: Transfer }, { model: Lockup }]
28+
})
29+
// Load the user and check there enough tokens available to fulfill the
30+
// transfer request
31+
if (!user) {
32+
throw new Error(`Could not find specified user id ${userId}`)
33+
}
34+
35+
// Sum the vested tokens for all of the users grants
36+
const vested = calculateVested(user.Grants)
37+
logger.info('Vested tokens', vested.toString())
38+
// Sum the unlocked tokens from lockup earnings
39+
const lockupEarnings = calculateUnlockedEarnings(user.Lockups)
40+
logger.info('Unlocked earnings from lockups', lockupEarnings.toString())
41+
// Sum amount withdrawn or pending in transfers
42+
const transferWithdrawnAmount = calculateWithdrawn(user.Transfers)
43+
logger.info(
44+
'Pending or transferred tokens',
45+
transferWithdrawnAmount.toString()
46+
)
47+
// Sum locked by lockups
48+
const lockedAmount = calculateLocked(user.Lockups)
49+
logger.info('Tokens in lockup', lockedAmount.toString())
50+
51+
// Calculate total available tokens
52+
const available = vested
53+
.plus(lockupEarnings)
54+
.minus(transferWithdrawnAmount)
55+
.minus(lockedAmount)
56+
57+
if (available < 0) {
58+
logger.info(`Amount of available OGN is below 0 for user ${user.email}`)
59+
60+
throw new RangeError(`Amount of available OGN is below 0`)
61+
}
62+
63+
if (amount > available) {
64+
logger.info(
65+
`Amount of ${amount} OGN exceeds the ${available} available for user ${user.email}`
66+
)
67+
68+
throw new RangeError(
69+
`Amount of ${amount} OGN exceeds the ${available} available balance`
70+
)
71+
}
72+
73+
return user
74+
}
75+
76+
module.exports = {
77+
calculateEarnings,
78+
hasBalance
79+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
const moment = require('moment')
2+
3+
const { LOCKUP_REQUEST } = require('../constants/events')
4+
const { Event, Lockup, sequelize } = require('../models')
5+
const { lockupBonusRate, lockupDuration } = require('../config')
6+
const { hasBalance } = require('./balance')
7+
const logger = require('../logger')
8+
9+
/**
10+
* Adds a lockup
11+
* @param userId - user id of the user adding the lockup
12+
* @param amount - the amount to be locked
13+
* @returns {Promise<Lockup>} Lockup object.
14+
*/
15+
async function addLockup(userId, amount, data = {}) {
16+
const user = await hasBalance(userId, amount)
17+
18+
let lockup
19+
const txn = await sequelize.transaction()
20+
try {
21+
const now = moment()
22+
lockup = await Lockup.create({
23+
userId: user.id,
24+
start: now,
25+
end: now.add(lockupDuration, 'months'),
26+
bonusRate: lockupBonusRate,
27+
amount,
28+
data
29+
})
30+
await Event.create({
31+
userId: user.id,
32+
action: LOCKUP_REQUEST,
33+
data: JSON.stringify({
34+
lockupId: lockup.id
35+
})
36+
})
37+
await txn.commit()
38+
} catch (e) {
39+
await txn.rollback()
40+
logger.error(`Failed to add lockup for user ${userId}: ${e}`)
41+
throw e
42+
}
43+
44+
return lockup
45+
}
46+
47+
module.exports = {
48+
addLockup
49+
}

0 commit comments

Comments
 (0)