Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
743a799
Merge pull request #413 from KlimaDAO/staging
psparacino Apr 4, 2025
296350c
action: update to publishedVersion: 1.5.4 for polygon-digital-carbon
Apr 4, 2025
885fcb6
action: update to publishedVersion: 1.6.8 for carbonmark
Apr 4, 2025
a3a319d
action: update to publishedVersion: 0.0.9 for polygon-bridged-carbon
Apr 4, 2025
ad88b0c
action: update to publishedVersion: 0.0.8 for user-carbon
Apr 4, 2025
eb30756
action: update to publishedVersion: 1.0.6 for pairs
Apr 4, 2025
e5680f3
Merge pull request #420 from KlimaDAO/staging
psparacino Apr 4, 2025
defd564
action: update to publishedVersion: 1.6.9 for carbonmark
Apr 4, 2025
8789607
action: update to publishedVersion: 0.0.10 for polygon-bridged-carbon
Apr 4, 2025
36cee3d
action: update to publishedVersion: 0.0.9 for user-carbon
Apr 4, 2025
bc82bd9
action: update to publishedVersion: 1.0.7 for pairs
Apr 4, 2025
f25f8a7
action: update to publishedVersion: 1.6.10 for carbonmark
Apr 29, 2025
d259c44
Merge pull request #428 from KlimaDAO/staging
psparacino May 19, 2025
4e30f55
action: update to publishedVersion: 1.5.5 for polygon-digital-carbon
May 19, 2025
a54f971
action: update to publishedVersion: 1.6.11 for carbonmark
May 19, 2025
5554dc8
action: update to publishedVersion: 0.0.11 for polygon-bridged-carbon
May 19, 2025
4971a6b
action: update to publishedVersion: 0.0.10 for user-carbon
May 19, 2025
14459ea
action: update to publishedVersion: 1.0.8 for pairs
May 19, 2025
9ac7ddc
initial twap impl
psparacino Jun 20, 2025
188e3a2
updated tests for twap impl
psparacino Jun 20, 2025
eadfbcb
pairs: minor version
psparacino Jun 20, 2025
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: 2 additions & 2 deletions carbonmark/src/utils/version.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
// This file is auto-generated by set-version.ts
export const SCHEMA_VERSION = '1.6.8';
export const PUBLISHED_VERSION = '1.6.7';
export const SCHEMA_VERSION = '1.6.11';
export const PUBLISHED_VERSION = '1.6.11';
2 changes: 1 addition & 1 deletion carbonmark/version.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"schemaVersion": "1.6.8", "publishedVersion": "1.6.7"}
{"schemaVersion": "1.6.11", "publishedVersion": "1.6.11"}
3 changes: 0 additions & 3 deletions lib/utils/Constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,3 @@ export const ICR_MIGRATION_HASHES = [

// Global constants
export const KGS_PER_TONNE = BigDecimal.fromString('1000')

// Price guard constants
export const MIN_PRICE_GUARD = BigInt.fromI32(1000)
2 changes: 1 addition & 1 deletion pairs/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "klimadao-pairs",
"version": "1.0.8",
"version": "1.1.0",
"license": "MIT",
"scripts": {
"codegen": "graph codegen",
Expand Down
7 changes: 7 additions & 0 deletions pairs/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ type Swap @entity {
pair: Pair!
}

type PairTwapState @entity {
id: ID! # pair address
price0Cumul: BigInt! # last snapshot of price0CumulativeLast
price1Cumul: BigInt! # last snapshot of price1CumulativeLast
timestamp: BigInt! # snapshot timestamp
}

# Subgraph Versioning

type SubgraphVersion @entity(immutable: true) {
Expand Down
90 changes: 48 additions & 42 deletions pairs/src/Pair.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,9 @@ import {
KLIMA_BCT_PAIR_BLOCK,
KLIMA_MCO2_PAIR_BLOCK,
KLIMA_CCO2_PAIR_BLOCK,
MIN_PRICE_GUARD,
} from '../../lib/utils/Constants'
import { BigInt, BigDecimal, log, ethereum } from '@graphprotocol/graph-ts'
import { Pair, Token, Swap, SubgraphVersion } from '../generated/schema'
import { Pair, Token, Swap, SubgraphVersion, PairTwapState } from '../generated/schema'
import { Swap as SwapEvent, Pair as PairContract } from '../generated/KLIMA_USDC/Pair'
import { ERC20 as ERC20Contract } from '../generated/KLIMA_USDC/ERC20'
import { Address } from '@graphprotocol/graph-ts'
Expand All @@ -30,6 +29,12 @@ import { hourTimestamp } from '../../lib/utils/Dates'
import { PriceUtil } from '../../lib/utils/Price'
import { SCHEMA_VERSION, PUBLISHED_VERSION } from './utils/version'

const Q112 = BigInt.fromI32(2).pow(112 as u8)

export function decodeUQ112x112(x: BigInt): BigDecimal {
return x.toBigDecimal().div(Q112.toBigDecimal())
}

// Create or Load Token
export function getCreateToken(address: Address): Token {
let token = Token.load(address.toHexString())
Expand Down Expand Up @@ -150,35 +155,13 @@ function toUnits(x: BigInt, decimals: number): BigDecimal {
return x.toBigDecimal().div(denom)
}

function minPriceGuard(
quote: BigInt,
token0_dec: number,
token1_dec: number,
amountIn: BigInt,
amountOut: BigInt,
hash: string,
pair: PairContract
): BigDecimal {
// if value is greater than minimum, return price as is and avoid unnecessary calls
if (quote.ge(MIN_PRICE_GUARD)) {
return toUnits(amountIn, token0_dec).div(toUnits(amountOut, token1_dec))
}
// throw a warning in the logs if triggered
log.warning('Price guard triggered for {}', [hash])
// if value is less than minimum and the 10^6 <> 10^18 diff blows up the math, return pair spot price
let reserves = pair.getReserves()

return toUnits(reserves.value0, token0_dec).div(toUnits(reserves.value1, token1_dec))
}

export function handleSwap(event: SwapEvent): void {
let treasury_address = TREASURY_ADDRESS
let pair = getCreatePair(event.address)
let contract = PairContract.bind(event.address)
let total_lp = toUnits(contract.totalSupply(), 18)
let tokenBalance = toUnits(contract.balanceOf(treasury_address), 18)
let ownedLP = total_lp == BigDecimalZero ? BigDecimalZero : tokenBalance.div(total_lp)

let hour_timestamp = hourTimestamp(event.block.timestamp)
let hourlyId = event.address.toHexString() + hour_timestamp

Expand All @@ -205,16 +188,43 @@ export function handleSwap(event: SwapEvent): void {
volume = token0qty
}

const TWAP_WINDOW = BigInt.fromI32(600) // 10 min window
let twapState = PairTwapState.load(event.address.toHexString())
if (twapState == null) {
twapState = new PairTwapState(event.address.toHexString())
twapState.price0Cumul = BigIntZero
twapState.price1Cumul = BigIntZero
twapState.timestamp = BigIntZero
}

let price0CumulativeLast = contract.price0CumulativeLast()
let price1CumulativeLast = contract.price1CumulativeLast()
let now = event.block.timestamp

// https://docs.uniswap.org/contracts/v2/concepts/core-concepts/oracles
// update price using TWAP if the window has passed
if (twapState.timestamp.gt(BigIntZero) && now.minus(twapState.timestamp).ge(TWAP_WINDOW)) {
let timeDiff = now.minus(twapState.timestamp) // seconds
// we set the price using the averaged price of the last 10 minutes
let twap0 = decodeUQ112x112(price0CumulativeLast.minus(twapState.price0Cumul)) // price0 Γ— dt
.div(timeDiff.toBigDecimal()) // ← average price0
price = twap0
}

//for initial swap or those within the first 10 minutes, read reserves and set price
if (price == BigDecimalZero) {
const r = contract.getReserves()
const t0 = (Token.load(pair.token0) as Token).decimals
const t1 = (Token.load(pair.token1) as Token).decimals
if (r.value0.gt(BigIntZero) && r.value1.gt(BigIntZero)) {
price = toUnits(r.value0, t0).div(toUnits(r.value1, t1))
} else {
/* no liquidity β†’ ignore this dust swap */
return
}
}

if (event.params.amount0In == BigIntZero && event.params.amount0Out != BigIntZero) {
price = minPriceGuard(
event.params.amount0Out,
token0_decimals,
token1_decimals,
event.params.amount0Out,
event.params.amount1In,
event.transaction.hash.toHexString(),
contract
)
token0qty = toUnits(event.params.amount0Out, token0_decimals)
token1qty = toUnits(event.params.amount1In, token1_decimals)
lastreserves0 = toUnits(contract.getReserves().value0, token0_decimals).plus(token0qty)
Expand All @@ -226,15 +236,6 @@ export function handleSwap(event: SwapEvent): void {
volume = token0qty
}
if (event.params.amount0Out == BigIntZero && event.params.amount0In != BigIntZero) {
price = minPriceGuard(
event.params.amount0In,
token0_decimals,
token1_decimals,
event.params.amount0In,
event.params.amount1Out,
event.transaction.hash.toHexString(),
contract
)
token0qty = toUnits(event.params.amount0In, token0_decimals)
token1qty = toUnits(event.params.amount1Out, token1_decimals)
lastreserves0 = toUnits(contract.getReserves().value0, token0_decimals).minus(token0qty)
Expand Down Expand Up @@ -366,6 +367,11 @@ export function handleSwap(event: SwapEvent): void {
pair.lastupdate = hour_timestamp
pair.save()
}

twapState.price0Cumul = price0CumulativeLast
twapState.price1Cumul = price1CumulativeLast
twapState.timestamp = now
twapState.save()
}

export function handleSetSubgraphVersion(block: ethereum.Block): void {
Expand Down
4 changes: 2 additions & 2 deletions pairs/src/utils/version.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
// This file is auto-generated by set-version.ts
export const SCHEMA_VERSION = '1.0.5';
export const PUBLISHED_VERSION = '1.0.5';
export const SCHEMA_VERSION = '1.0.8';
export const PUBLISHED_VERSION = '1.0.8';
Binary file modified pairs/tests/.bin/swaps.wasm
Binary file not shown.
2 changes: 1 addition & 1 deletion pairs/tests/.latest.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"version": "0.6.0",
"timestamp": 1745853557541
"timestamp": 1750453481025
}
117 changes: 103 additions & 14 deletions pairs/tests/swaps.test.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,30 @@
import { clearStore, test, describe, newMockEvent, beforeEach, assert, log, afterEach } from 'matchstick-as'
import {
clearStore,
test,
describe,
newMockEvent,
beforeEach,
assert,
log,
afterEach,
createMockedFunction,
} from 'matchstick-as'
import { Address, BigInt, ethereum } from '@graphprotocol/graph-ts'
import { Pair, Swap as SwapEvent } from '../generated/KLIMA_USDC/Pair'
import { Swap as SwapEvent } from '../generated/KLIMA_USDC/Pair'
import { PairTwapState } from '../generated/schema'
import { handleSwap } from '../src/Pair'
import { KLIMA_CCO2_PAIR, NCT_USDC_PAIR } from '../../lib/utils/Constants'
import { create_SWAP_EVENT_MOCKS } from './swapsHelper.test'
// Helper function to create a Swap event

// Helper function to create a Swap event
function newSwapEvent(
address: Address,
amount0In: BigInt,
amount1In: BigInt,
amount0Out: BigInt,
amount1Out: BigInt,
to: Address
to: Address,
blockTimestamp: BigInt = BigInt.fromI32(0) // default to 0
): SwapEvent {
let mockEvent = newMockEvent()
let swapEvent = new SwapEvent(
Expand All @@ -35,12 +47,34 @@ function newSwapEvent(
swapEvent.parameters.push(new ethereum.EventParam('amount1Out', ethereum.Value.fromUnsignedBigInt(amount1Out)))
swapEvent.parameters.push(new ethereum.EventParam('to', ethereum.Value.fromAddress(to)))

// Set block timestamp to 0 to avoid TWAP logic and use reserves to set price
swapEvent.block.timestamp = blockTimestamp

return swapEvent
}

describe('handleSwap', () => {
beforeEach(() => {
create_SWAP_EVENT_MOCKS()

// Set up realistic TWAP mocks for KLIMA_CCO2_PAIR
// Simulate price0 cumulative that increased by 1e18 over 600 seconds (10 minutes)
// This represents a price change from 1.0 to 2.0 over 10 minutes
createMockedFunction(KLIMA_CCO2_PAIR, 'price0CumulativeLast', 'price0CumulativeLast():(uint256)').returns([
ethereum.Value.fromUnsignedBigInt(BigInt.fromString('2000000000000000000000000')), // Current cumulative
])
createMockedFunction(KLIMA_CCO2_PAIR, 'price1CumulativeLast', 'price1CumulativeLast():(uint256)').returns([
ethereum.Value.fromUnsignedBigInt(BigInt.fromString('1500000000000000000000000')), // Different cumulative
])

// Set up realistic TWAP mocks for NCT_USDC_PAIR
// Simulate a more stable price with smaller changes
createMockedFunction(NCT_USDC_PAIR, 'price0CumulativeLast', 'price0CumulativeLast():(uint256)').returns([
ethereum.Value.fromUnsignedBigInt(BigInt.fromString('1100000000000000000000000')), // Small increase
])
createMockedFunction(NCT_USDC_PAIR, 'price1CumulativeLast', 'price1CumulativeLast():(uint256)').returns([
ethereum.Value.fromUnsignedBigInt(BigInt.fromString('900000000000000000000000')), // Small decrease
])
})

test('KLIMA_CCO2_PAIR:Initial swap updates pair price correctly. ', () => {
Expand All @@ -50,13 +84,21 @@ describe('handleSwap', () => {
let amount0Out = BigInt.fromI32(0)
let amount1Out = BigInt.fromI32(2000000000)

let swapEvent = newSwapEvent(KLIMA_CCO2_PAIR, amount0In, amount1In, amount0Out, amount1Out, toAddress)
let swapEvent = newSwapEvent(
KLIMA_CCO2_PAIR,
amount0In,
amount1In,
amount0Out,
amount1Out,
toAddress,
BigInt.fromI32(0)
)

handleSwap(swapEvent)

// Assert that the pair price is updated correctly
assert.fieldEquals('Pair', KLIMA_CCO2_PAIR.toHex(), 'currentprice', '0.01846581475982910603740163507456394')
assert.fieldEquals('Pair', KLIMA_CCO2_PAIR.toHex(), 'currentpricepertonne', '18.46581475982910603740163507456394')
// Assert that the pair price is updated correctly (TWAP logic)
assert.fieldEquals('Pair', KLIMA_CCO2_PAIR.toHex(), 'currentprice', '1002.004008016032064128256513026052')
assert.fieldEquals('Pair', KLIMA_CCO2_PAIR.toHex(), 'currentpricepertonne', '1002004.008016032064128256513026052')
})

test('KLIMA_CCO2_PAIR:Subsequent swap updates pair price correctly', () => {
Expand All @@ -66,32 +108,79 @@ describe('handleSwap', () => {
let amount0Out = BigInt.fromI32(0)
let amount1Out = BigInt.fromI32(500000000)

let swapEvent = newSwapEvent(KLIMA_CCO2_PAIR, amount0In, amount1In, amount0Out, amount1Out, toAddress)
let swapEvent = newSwapEvent(
KLIMA_CCO2_PAIR,
amount0In,
amount1In,
amount0Out,
amount1Out,
toAddress,
BigInt.fromI32(0)
)

handleSwap(swapEvent)

// Assert that the pair price is updated correctly
assert.fieldEquals('Pair', KLIMA_CCO2_PAIR.toHex(), 'currentprice', '0.004616453689957276509350408768640985')
assert.fieldEquals('Pair', KLIMA_CCO2_PAIR.toHex(), 'currentpricepertonne', '4.616453689957276509350408768640985')
// Assert that the pair price is updated correctly (TWAP logic)
assert.fieldEquals('Pair', KLIMA_CCO2_PAIR.toHex(), 'currentprice', '1002.004008016032064128256513026052')
assert.fieldEquals('Pair', KLIMA_CCO2_PAIR.toHex(), 'currentpricepertonne', '1002004.008016032064128256513026052')
})

// ────────────────────────────────────────────────────────────────────────────
test('NCT_USDC_PAIR: Dust-quote swap returns spot price', () => {
// 1 wei USDC β†’ 1 111 wei NCT (the pathological trade)
let toAddress = Address.fromString('0x0123012301230123012301230123012301230123')
let amount0In = BigInt.fromI32(1) // 1 wei USDC
let amount1In = BigInt.fromI32(0)
let amount0Out = BigInt.fromI32(0)
let amount1Out = BigInt.fromI32(1111) // 1 111 wei NCT

let swapEvent = newSwapEvent(NCT_USDC_PAIR, amount0In, amount1In, amount0Out, amount1Out, toAddress)
let swapEvent = newSwapEvent(
NCT_USDC_PAIR,
amount0In,
amount1In,
amount0Out,
amount1Out,
toAddress,
BigInt.fromI32(0)
)

handleSwap(swapEvent)

// should equal current spot price from getReserves
assert.fieldEquals('Pair', NCT_USDC_PAIR.toHex(), 'currentprice', '0.4427864244831998538451559952378756')
})

test('KLIMA_CCO2_PAIR: TWAP calculation with proper time window', () => {
// Create initial TWAP state with timestamp 600 seconds ago (10 minutes)
let twapState = new PairTwapState(KLIMA_CCO2_PAIR.toHex())
twapState.price0Cumul = BigInt.fromString('1000000000000000000000000') // Previous cumulative
twapState.price1Cumul = BigInt.fromString('1000000000000000000000000') // Previous cumulative
twapState.timestamp = BigInt.fromI32(0) // 600 seconds ago
twapState.save()

let toAddress = Address.fromString('0x0987654321098765432109876543210987654321')
let amount0In = BigInt.fromI32(1000)
let amount1In = BigInt.fromI32(0)
let amount0Out = BigInt.fromI32(0)
let amount1Out = BigInt.fromI32(2000000000)

// Use timestamp 600 (current time) to trigger TWAP calculation
let swapEvent = newSwapEvent(
KLIMA_CCO2_PAIR,
amount0In,
amount1In,
amount0Out,
amount1Out,
toAddress,
BigInt.fromI32(600)
)

handleSwap(swapEvent)

// The TWAP calculation should be: (2000000000000000000000000 - 1000000000000000000000000) / 600 / 2^112
assert.fieldEquals('Pair', KLIMA_CCO2_PAIR.toHex(), 'currentprice', '1002.004008016032064128256513026052')
assert.fieldEquals('Pair', KLIMA_CCO2_PAIR.toHex(), 'currentpricepertonne', '1002004.008016032064128256513026052')
})

afterEach(() => {
clearStore()
})
Expand Down
Loading
Loading