move transaction link validUntil correction into database migration

This commit is contained in:
einhornimmond 2025-12-30 18:13:03 +01:00
parent 170d4bcb78
commit d9666f3a88
5 changed files with 93 additions and 36 deletions

View File

@ -1,3 +1,10 @@
import Decimal from 'decimal.js-light'
import { DECAY_FACTOR, reverseLegacyDecay } from 'shared'
function calculateEffectiveSeconds(holdOriginal: Decimal, holdCorrected: Decimal): Decimal {
return holdOriginal.div(holdCorrected).ln().div(DECAY_FACTOR.ln())
}
export async function upgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
/**
* Migration: Correct historical inconsistencies in transactions, users, and contribution_links.
@ -34,6 +41,77 @@ export async function upgrade(queryFn: (query: string, values?: any[]) => Promis
* ensuring blockchain consistency for contributions.
*/
/**
* Fix 0: Update transaction links to match holdAvailableAmount with validUntil, because the old formula lead to incorrect values
*/
let sumCount = 0
let count = 0
let lastProcessedId = 0
const LIMIT = 200
do {
const rows = await queryFn(
`
SELECT id, amount, hold_available_amount, validUntil, createdAt, redeemedAt, deletedAt
FROM transaction_links
WHERE id > ?
ORDER BY id ASC
LIMIT ?
`,
[lastProcessedId, LIMIT],
)
if (!rows.length) {
break
}
const updates: Array<{ id: number; newValidUntil: string }> = []
for (const row of rows) {
const validUntil = new Date(row.validUntil)
const redeemedAt = row.redeemedAt ? new Date(row.redeemedAt) : null
const deletedAt = row.deletedAt ? new Date(row.deletedAt) : null
const createdAt = new Date(row.createdAt)
const amount = new Decimal(row.amount)
const duration = (validUntil.getTime() - createdAt.getTime()) / 1000
const blockedAmountCorrected = reverseLegacyDecay(amount, duration)
// fix only if the difference is big enough to have an impact
if (blockedAmountCorrected.sub(amount).abs().lt(new Decimal('0.001'))) {
continue
}
const holdAvailableAmount = new Decimal(row.hold_available_amount)
const secondsDiff = calculateEffectiveSeconds(
new Decimal(holdAvailableAmount.toString()),
new Decimal(blockedAmountCorrected.toString()),
)
const newValidUntil = new Date(validUntil.getTime() - secondsDiff.mul(1000).toNumber())
if (
(redeemedAt && redeemedAt.getTime() < validUntil.getTime()) ||
(deletedAt && deletedAt.getTime() < validUntil.getTime())
) {
continue
}
updates.push({
id: row.id,
newValidUntil: newValidUntil.toISOString().replace('T', ' ').replace('Z', ''),
})
}
if (updates.length > 0) {
const caseStatements = updates.map((u) => `WHEN ${u.id} THEN '${u.newValidUntil}'`).join('\n')
await queryFn(
`
UPDATE transaction_links
SET validUntil = CASE id
${caseStatements}
END
WHERE id IN (?)
`,
[updates.map((u) => u.id)],
)
sumCount += updates.length
}
count = rows.length
lastProcessedId = rows[rows.length - 1].id
} while (count === LIMIT)
///*/
/**
* Fix 1: Remove self-signed contributions.
*

View File

@ -127,32 +127,8 @@ export class TransactionLinkFundingsSyncRole extends AbstractSyncRole<Transactio
if (!senderKeyPair || !senderPublicKey || !recipientKeyPair || !recipientPublicKey) {
throw new Error(`missing key for ${this.itemTypeName()}: ${JSON.stringify(item, null, 2)}`)
}
let endDateTime: number = item.validUntil.getTime()
if (item.redeemedAt) {
endDateTime = item.redeemedAt.getTime() + (1000 * 120)
} else if (item.deletedAt) {
endDateTime = item.deletedAt.getTime() + (1000 * 120)
} else {
const duration = new DurationSeconds((endDateTime - item.createdAt.getTime()) / 1000)
const blockedAmount = GradidoUnit.fromString(reverseLegacyDecay(new Decimal(item.amount.toString()), duration.getSeconds()).toString())
const secondsDiff = calculateEffectiveSeconds(
new Decimal(item.holdAvailableAmount.toString()),
new Decimal(blockedAmount.toString())
)
endDateTime = endDateTime - secondsDiff.toNumber() * 1000
}
if (endDateTime > item.validUntil.getTime()) {
endDateTime = item.validUntil.getTime()
}
let duration = new DurationSeconds((endDateTime - item.createdAt.getTime()) / 1000)
const hourInSeconds = 60 * 60
if (duration.getSeconds() < hourInSeconds) {
duration = new DurationSeconds(hourInSeconds)
}
let duration = new DurationSeconds((item.validUntil.getTime() - item.createdAt.getTime()) / 1000)
let blockedAmount = GradidoUnit.fromString(reverseLegacyDecay(new Decimal(item.amount.toString()), duration.getSeconds()).toString())
blockedAmount = blockedAmount.add(GradidoUnit.fromGradidoCent(1))
// let blockedAmount = decayedAmount.calculateCompoundInterest(duration.getSeconds())
let accountBalances: AccountBalances
try {
accountBalances = this.calculateBalances(item, blockedAmount, communityContext, senderPublicKey, recipientPublicKey)
@ -171,11 +147,6 @@ export class TransactionLinkFundingsSyncRole extends AbstractSyncRole<Transactio
throw e
}
}
/*
const decayedAmount = GradidoUnit.fromString(legacyCalculateDecay(new Decimal(item.amount.toString()), item.createdAt, item.validUntil).toString())
const blockedAmount = item.amount.add(item.amount.minus(decayedAmount))
*/
try {
addToBlockchain(
this.buildTransaction(item, blockedAmount, duration, senderKeyPair, recipientKeyPair),

View File

@ -44,10 +44,6 @@ export function reverseLegacyDecay(result: Decimal, seconds: number): Decimal {
return result.div(FACTOR.pow(seconds).toString())
}
export function calculateEffectiveSeconds(holdOriginal: Decimal, holdCorrected: Decimal): Decimal {
return holdOriginal.div(holdCorrected).ln().div(FACTOR.ln());
}
export function legacyCalculateDecay(amount: Decimal, from: Date, to: Date): Decimal {
const fromMs = from.getTime()
const toMs = to.getTime()

View File

@ -1,4 +1,7 @@
import Decimal from 'decimal.js-light'
export const DECAY_START_TIME = new Date('2021-05-13T17:46:31Z')
export const DECAY_FACTOR = new Decimal('0.99999997803504048973201202316767079413460520837376')
export const SECONDS_PER_YEAR_GREGORIAN_CALENDER = 31556952.0
export const LOG4JS_BASE_CATEGORY_NAME = 'shared'
export const REDEEM_JWT_TOKEN_EXPIRATION = '10m'

View File

@ -1,6 +1,6 @@
import { Decimal } from 'decimal.js-light'
import { DECAY_START_TIME, SECONDS_PER_YEAR_GREGORIAN_CALENDER } from '../const'
import { DECAY_FACTOR, DECAY_START_TIME, SECONDS_PER_YEAR_GREGORIAN_CALENDER } from '../const'
Decimal.set({
precision: 25,
@ -16,21 +16,30 @@ export interface Decay {
duration: number | null
}
// legacy decay formula
export function decayFormula(value: Decimal, seconds: number): Decimal {
// TODO why do we need to convert this here to a string to work properly?
// chatgpt: We convert to string here to avoid precision loss:
// .pow(seconds) can internally round the result, especially for large values of `seconds`.
// Using .toString() ensures full precision is preserved in the multiplication.
return value.mul(
new Decimal('0.99999997803504048973201202316767079413460520837376').pow(seconds).toString(),
DECAY_FACTOR.pow(seconds).toString(),
)
}
// legacy reverse decay formula
export function reverseLegacyDecay(result: Decimal, seconds: number): Decimal {
return result.div(DECAY_FACTOR.pow(seconds).toString())
}
// fast and more correct decay formula
export function decayFormulaFast(value: Decimal, seconds: number): Decimal {
return value.mul(
new Decimal(2).pow(new Decimal(-seconds).div(new Decimal(SECONDS_PER_YEAR_GREGORIAN_CALENDER))),
)
}
// compound interest formula, the reverse decay formula for decayFormulaFast
export function compoundInterest(value: Decimal, seconds: number): Decimal {
return value.mul(
new Decimal(2).pow(new Decimal(seconds).div(new Decimal(SECONDS_PER_YEAR_GREGORIAN_CALENDER))),