Skip to content

Commit 946ea1d

Browse files
authored
Merge pull request #11 from edgeandnode/tmigone/fee-cuts
feat: add provision fee cuts
2 parents 021d039 + e0552be commit 946ea1d

10 files changed

Lines changed: 542 additions & 2 deletions

File tree

packages/subgraph/schema.graphql

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,8 @@ type Provision @entity(immutable: false) {
189189
dataService: DataService!
190190
"Thaw requests for this provision"
191191
thawRequests: [ProvisionThawRequest!]! @derivedFrom(field: "provision")
192+
"Fee cuts for delegators by payment type"
193+
feeCuts: [ProvisionFeeCut!]! @derivedFrom(field: "provision")
192194

193195
# Tokens
194196
"Tokens currently provisioned"
@@ -221,6 +223,27 @@ type Provision @entity(immutable: false) {
221223
updatedAt: BigInt!
222224
}
223225

226+
type ProvisionFeeCut @entity(immutable: false) {
227+
"Composite ID: provision-paymentType"
228+
id: Bytes!
229+
230+
# Relationships
231+
"Provision this fee cut belongs to"
232+
provision: Provision!
233+
234+
# State
235+
"Payment type (maps to PaymentTypes enum: 0 = QueryFee, 1 = IndexingFee, 2 = IndexingReward, ...)"
236+
paymentType: Int!
237+
"Fee cut percentage (PPM)"
238+
feeCut: BigInt!
239+
240+
# Metadata
241+
"Block number when entity was last updated"
242+
updatedAtBlock: BigInt!
243+
"Timestamp when entity was last updated"
244+
updatedAt: BigInt!
245+
}
246+
224247
type ProvisionThawRequest @entity(immutable: false) {
225248
"Thaw request ID (bytes32) emitted by ThawRequestCreated event"
226249
id: Bytes!
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { BigInt, Bytes, ethereum } from "@graphprotocol/graph-ts"
2+
import { ProvisionFeeCut } from "../../generated/schema"
3+
import { BIGINT_ZERO } from "../common/constants"
4+
import { getProvisionId } from "./provision"
5+
6+
export function getProvisionFeeCutId(serviceProvider: Bytes, dataService: Bytes, paymentType: i32): Bytes {
7+
let provisionId = getProvisionId(serviceProvider, dataService)
8+
return provisionId.concat(Bytes.fromI32(paymentType))
9+
}
10+
11+
export class ProvisionFeeCutResult {
12+
entity: ProvisionFeeCut
13+
isNew: boolean
14+
15+
constructor(entity: ProvisionFeeCut, isNew: boolean) {
16+
this.entity = entity
17+
this.isNew = isNew
18+
}
19+
}
20+
21+
export function getOrCreateProvisionFeeCut(
22+
serviceProvider: Bytes,
23+
dataService: Bytes,
24+
paymentType: i32,
25+
blockNumber: BigInt,
26+
timestamp: BigInt
27+
): ProvisionFeeCutResult {
28+
let id = getProvisionFeeCutId(serviceProvider, dataService, paymentType)
29+
let entity = ProvisionFeeCut.load(id)
30+
let isNew = entity == null
31+
32+
if (entity == null) {
33+
entity = new ProvisionFeeCut(id)
34+
entity.provision = getProvisionId(serviceProvider, dataService)
35+
entity.paymentType = paymentType
36+
entity.feeCut = BIGINT_ZERO
37+
entity.updatedAtBlock = blockNumber
38+
entity.updatedAt = timestamp
39+
}
40+
41+
return new ProvisionFeeCutResult(entity, isNew)
42+
}
43+
44+
export function saveProvisionFeeCut(feeCut: ProvisionFeeCut, block: ethereum.Block): void {
45+
feeCut.updatedAtBlock = block.number
46+
feeCut.updatedAt = block.timestamp
47+
feeCut.save()
48+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { Bytes } from "@graphprotocol/graph-ts"
2+
import { DelegationFeeCutSet } from "../../generated/HorizonStaking/HorizonStaking"
3+
import {
4+
getOrCreateProvisionFeeCut,
5+
saveProvisionFeeCut,
6+
} from "../entities/provisionFeeCut"
7+
8+
/**
9+
* Handles DelegationFeeCutSet event.
10+
* Creates or updates a ProvisionFeeCut entity when a service provider sets
11+
* their fee cut percentage for a specific payment type on a provision.
12+
*/
13+
export function handleDelegationFeeCutSet(event: DelegationFeeCutSet): void {
14+
let serviceProviderBytes = Bytes.fromHexString(
15+
event.params.serviceProvider.toHexString()
16+
) as Bytes
17+
let dataServiceBytes = Bytes.fromHexString(
18+
event.params.verifier.toHexString()
19+
) as Bytes
20+
21+
let feeCut = getOrCreateProvisionFeeCut(
22+
serviceProviderBytes,
23+
dataServiceBytes,
24+
event.params.paymentType,
25+
event.block.number,
26+
event.block.timestamp
27+
)
28+
29+
feeCut.entity.feeCut = event.params.feeCut
30+
31+
saveProvisionFeeCut(feeCut.entity, event.block)
32+
}

packages/subgraph/src/mapping.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,4 @@ export {
2525
handleThawRequestCreated,
2626
handleThawRequestFulfilled
2727
} from "./handlers/thawRequest"
28+
export { handleDelegationFeeCutSet } from "./handlers/feeCut"

packages/subgraph/subgraph.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ dataSources:
2222
- Provision
2323
- DelegationPool
2424
- ProvisionThawRequest
25+
- ProvisionFeeCut
2526
abis:
2627
- name: HorizonStaking
2728
file: ./abis/HorizonStaking.json
@@ -59,6 +60,8 @@ dataSources:
5960
handler: handleDelegatedTokensWithdrawn
6061
- event: DelegationSlashed(indexed address,indexed address,uint256)
6162
handler: handleDelegationSlashed
63+
- event: DelegationFeeCutSet(indexed address,indexed address,indexed uint8,uint256)
64+
handler: handleDelegationFeeCutSet
6265
# Thaw request events
6366
- event: ThawRequestCreated(indexed uint8,indexed address,indexed address,address,uint256,uint64,bytes32,uint256)
6467
handler: handleThawRequestCreated
Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
import {
2+
describe,
3+
test,
4+
beforeEach,
5+
clearStore,
6+
assert,
7+
newTypedMockEvent,
8+
} from "matchstick-as"
9+
import { Address, BigInt, Bytes, ethereum } from "@graphprotocol/graph-ts"
10+
import {
11+
DelegationFeeCutSet,
12+
HorizonStakeDeposited,
13+
ProvisionCreated,
14+
} from "../generated/HorizonStaking/HorizonStaking"
15+
import { handleDelegationFeeCutSet } from "../src/handlers/feeCut"
16+
import { handleHorizonStakeDeposited } from "../src/handlers/staking"
17+
import { handleProvisionCreated } from "../src/handlers/provision"
18+
import { getProvisionFeeCutId } from "../src/entities/provisionFeeCut"
19+
import { getProvisionId } from "../src/entities/provision"
20+
21+
// Test addresses
22+
const SP_ADDRESS = Address.fromString("0x1234567890123456789012345678901234567890")
23+
const VERIFIER_ADDRESS = Address.fromString("0xabcdefabcdefabcdefabcdefabcdefabcdefabcd")
24+
25+
// Payment types (from IGraphPayments.PaymentTypes enum)
26+
const PAYMENT_TYPE_QUERY_FEE = 0
27+
const PAYMENT_TYPE_INDEXING_FEE = 1
28+
const PAYMENT_TYPE_INDEXING_REWARD = 2
29+
30+
// Helper to create stake deposit
31+
function createStakeDepositedEvent(serviceProvider: Address, tokens: BigInt): HorizonStakeDeposited {
32+
let event = newTypedMockEvent<HorizonStakeDeposited>()
33+
event.parameters = new Array()
34+
event.parameters.push(new ethereum.EventParam("serviceProvider", ethereum.Value.fromAddress(serviceProvider)))
35+
event.parameters.push(new ethereum.EventParam("tokens", ethereum.Value.fromUnsignedBigInt(tokens)))
36+
event.block.number = BigInt.fromI32(100)
37+
event.block.timestamp = BigInt.fromI32(1000)
38+
return event
39+
}
40+
41+
// Helper to create provision
42+
function createProvisionCreatedEvent(
43+
serviceProvider: Address,
44+
verifier: Address,
45+
tokens: BigInt
46+
): ProvisionCreated {
47+
let event = newTypedMockEvent<ProvisionCreated>()
48+
event.parameters = new Array()
49+
event.parameters.push(new ethereum.EventParam("serviceProvider", ethereum.Value.fromAddress(serviceProvider)))
50+
event.parameters.push(new ethereum.EventParam("verifier", ethereum.Value.fromAddress(verifier)))
51+
event.parameters.push(new ethereum.EventParam("tokens", ethereum.Value.fromUnsignedBigInt(tokens)))
52+
event.parameters.push(new ethereum.EventParam("maxVerifierCut", ethereum.Value.fromUnsignedBigInt(BigInt.fromI32(100000))))
53+
event.parameters.push(new ethereum.EventParam("thawingPeriod", ethereum.Value.fromUnsignedBigInt(BigInt.fromI32(2592000))))
54+
event.block.number = BigInt.fromI32(200)
55+
event.block.timestamp = BigInt.fromI32(2000)
56+
return event
57+
}
58+
59+
// Helper to create DelegationFeeCutSet event
60+
function createDelegationFeeCutSetEvent(
61+
serviceProvider: Address,
62+
verifier: Address,
63+
paymentType: i32,
64+
feeCut: BigInt
65+
): DelegationFeeCutSet {
66+
let event = newTypedMockEvent<DelegationFeeCutSet>()
67+
event.parameters = new Array()
68+
event.parameters.push(new ethereum.EventParam("serviceProvider", ethereum.Value.fromAddress(serviceProvider)))
69+
event.parameters.push(new ethereum.EventParam("verifier", ethereum.Value.fromAddress(verifier)))
70+
event.parameters.push(new ethereum.EventParam("paymentType", ethereum.Value.fromI32(paymentType)))
71+
event.parameters.push(new ethereum.EventParam("feeCut", ethereum.Value.fromUnsignedBigInt(feeCut)))
72+
event.block.number = BigInt.fromI32(300)
73+
event.block.timestamp = BigInt.fromI32(3000)
74+
return event
75+
}
76+
77+
function setupServiceProviderAndProvision(): void {
78+
// Deposit stake
79+
let stakeTokens = BigInt.fromString("10000000000000000000000") // 10000 GRT
80+
let depositEvent = createStakeDepositedEvent(SP_ADDRESS, stakeTokens)
81+
handleHorizonStakeDeposited(depositEvent)
82+
83+
// Create provision
84+
let provisionTokens = BigInt.fromString("5000000000000000000000") // 5000 GRT
85+
let provisionEvent = createProvisionCreatedEvent(SP_ADDRESS, VERIFIER_ADDRESS, provisionTokens)
86+
handleProvisionCreated(provisionEvent)
87+
}
88+
89+
function getFeeCutIdString(sp: Address, verifier: Address, paymentType: i32): string {
90+
return getProvisionFeeCutId(
91+
Bytes.fromHexString(sp.toHexString()),
92+
Bytes.fromHexString(verifier.toHexString()),
93+
paymentType
94+
).toHexString()
95+
}
96+
97+
function getProvisionIdString(sp: Address, verifier: Address): string {
98+
return getProvisionId(
99+
Bytes.fromHexString(sp.toHexString()),
100+
Bytes.fromHexString(verifier.toHexString())
101+
).toHexString()
102+
}
103+
104+
describe("DelegationFeeCutSet", () => {
105+
beforeEach(() => {
106+
clearStore()
107+
})
108+
109+
test("creates ProvisionFeeCut entity for query fee type", () => {
110+
setupServiceProviderAndProvision()
111+
112+
let feeCut = BigInt.fromI32(100000) // 10% in PPM
113+
let event = createDelegationFeeCutSetEvent(
114+
SP_ADDRESS,
115+
VERIFIER_ADDRESS,
116+
PAYMENT_TYPE_QUERY_FEE,
117+
feeCut
118+
)
119+
handleDelegationFeeCutSet(event)
120+
121+
let entityId = getFeeCutIdString(SP_ADDRESS, VERIFIER_ADDRESS, PAYMENT_TYPE_QUERY_FEE)
122+
let provisionId = getProvisionIdString(SP_ADDRESS, VERIFIER_ADDRESS)
123+
124+
assert.entityCount("ProvisionFeeCut", 1)
125+
assert.fieldEquals("ProvisionFeeCut", entityId, "provision", provisionId)
126+
assert.fieldEquals("ProvisionFeeCut", entityId, "paymentType", PAYMENT_TYPE_QUERY_FEE.toString())
127+
assert.fieldEquals("ProvisionFeeCut", entityId, "feeCut", feeCut.toString())
128+
assert.fieldEquals("ProvisionFeeCut", entityId, "updatedAtBlock", "300")
129+
assert.fieldEquals("ProvisionFeeCut", entityId, "updatedAt", "3000")
130+
})
131+
132+
test("creates separate entities for different payment types", () => {
133+
setupServiceProviderAndProvision()
134+
135+
// Set query fee cut
136+
let queryFeeCut = BigInt.fromI32(100000) // 10%
137+
let queryEvent = createDelegationFeeCutSetEvent(
138+
SP_ADDRESS,
139+
VERIFIER_ADDRESS,
140+
PAYMENT_TYPE_QUERY_FEE,
141+
queryFeeCut
142+
)
143+
handleDelegationFeeCutSet(queryEvent)
144+
145+
// Set indexing fee cut
146+
let indexingFeeCut = BigInt.fromI32(200000) // 20%
147+
let indexingEvent = createDelegationFeeCutSetEvent(
148+
SP_ADDRESS,
149+
VERIFIER_ADDRESS,
150+
PAYMENT_TYPE_INDEXING_FEE,
151+
indexingFeeCut
152+
)
153+
indexingEvent.block.number = BigInt.fromI32(301)
154+
indexingEvent.block.timestamp = BigInt.fromI32(3100)
155+
handleDelegationFeeCutSet(indexingEvent)
156+
157+
// Set indexing reward cut
158+
let rewardFeeCut = BigInt.fromI32(50000) // 5%
159+
let rewardEvent = createDelegationFeeCutSetEvent(
160+
SP_ADDRESS,
161+
VERIFIER_ADDRESS,
162+
PAYMENT_TYPE_INDEXING_REWARD,
163+
rewardFeeCut
164+
)
165+
rewardEvent.block.number = BigInt.fromI32(302)
166+
rewardEvent.block.timestamp = BigInt.fromI32(3200)
167+
handleDelegationFeeCutSet(rewardEvent)
168+
169+
assert.entityCount("ProvisionFeeCut", 3)
170+
171+
let queryId = getFeeCutIdString(SP_ADDRESS, VERIFIER_ADDRESS, PAYMENT_TYPE_QUERY_FEE)
172+
let indexingId = getFeeCutIdString(SP_ADDRESS, VERIFIER_ADDRESS, PAYMENT_TYPE_INDEXING_FEE)
173+
let rewardId = getFeeCutIdString(SP_ADDRESS, VERIFIER_ADDRESS, PAYMENT_TYPE_INDEXING_REWARD)
174+
175+
assert.fieldEquals("ProvisionFeeCut", queryId, "paymentType", PAYMENT_TYPE_QUERY_FEE.toString())
176+
assert.fieldEquals("ProvisionFeeCut", queryId, "feeCut", queryFeeCut.toString())
177+
178+
assert.fieldEquals("ProvisionFeeCut", indexingId, "paymentType", PAYMENT_TYPE_INDEXING_FEE.toString())
179+
assert.fieldEquals("ProvisionFeeCut", indexingId, "feeCut", indexingFeeCut.toString())
180+
181+
assert.fieldEquals("ProvisionFeeCut", rewardId, "paymentType", PAYMENT_TYPE_INDEXING_REWARD.toString())
182+
assert.fieldEquals("ProvisionFeeCut", rewardId, "feeCut", rewardFeeCut.toString())
183+
})
184+
185+
test("updates existing fee cut for same payment type", () => {
186+
setupServiceProviderAndProvision()
187+
188+
// Initial fee cut
189+
let initialFeeCut = BigInt.fromI32(100000) // 10%
190+
let event1 = createDelegationFeeCutSetEvent(
191+
SP_ADDRESS,
192+
VERIFIER_ADDRESS,
193+
PAYMENT_TYPE_QUERY_FEE,
194+
initialFeeCut
195+
)
196+
handleDelegationFeeCutSet(event1)
197+
198+
let entityId = getFeeCutIdString(SP_ADDRESS, VERIFIER_ADDRESS, PAYMENT_TYPE_QUERY_FEE)
199+
assert.fieldEquals("ProvisionFeeCut", entityId, "feeCut", initialFeeCut.toString())
200+
assert.fieldEquals("ProvisionFeeCut", entityId, "updatedAtBlock", "300")
201+
202+
// Update fee cut
203+
let updatedFeeCut = BigInt.fromI32(150000) // 15%
204+
let event2 = createDelegationFeeCutSetEvent(
205+
SP_ADDRESS,
206+
VERIFIER_ADDRESS,
207+
PAYMENT_TYPE_QUERY_FEE,
208+
updatedFeeCut
209+
)
210+
event2.block.number = BigInt.fromI32(400)
211+
event2.block.timestamp = BigInt.fromI32(4000)
212+
handleDelegationFeeCutSet(event2)
213+
214+
// Should still be 1 entity, but updated
215+
assert.entityCount("ProvisionFeeCut", 1)
216+
assert.fieldEquals("ProvisionFeeCut", entityId, "feeCut", updatedFeeCut.toString())
217+
assert.fieldEquals("ProvisionFeeCut", entityId, "updatedAtBlock", "400")
218+
assert.fieldEquals("ProvisionFeeCut", entityId, "updatedAt", "4000")
219+
})
220+
221+
test("handles fee cut set to zero", () => {
222+
setupServiceProviderAndProvision()
223+
224+
// Set fee cut to zero (100% goes to delegators)
225+
let feeCut = BigInt.fromI32(0)
226+
let event = createDelegationFeeCutSetEvent(
227+
SP_ADDRESS,
228+
VERIFIER_ADDRESS,
229+
PAYMENT_TYPE_QUERY_FEE,
230+
feeCut
231+
)
232+
handleDelegationFeeCutSet(event)
233+
234+
let entityId = getFeeCutIdString(SP_ADDRESS, VERIFIER_ADDRESS, PAYMENT_TYPE_QUERY_FEE)
235+
assert.fieldEquals("ProvisionFeeCut", entityId, "feeCut", "0")
236+
})
237+
238+
test("handles max fee cut (100%)", () => {
239+
setupServiceProviderAndProvision()
240+
241+
// Set fee cut to 100% (1000000 PPM)
242+
let feeCut = BigInt.fromI32(1000000) // 100% in PPM
243+
let event = createDelegationFeeCutSetEvent(
244+
SP_ADDRESS,
245+
VERIFIER_ADDRESS,
246+
PAYMENT_TYPE_QUERY_FEE,
247+
feeCut
248+
)
249+
handleDelegationFeeCutSet(event)
250+
251+
let entityId = getFeeCutIdString(SP_ADDRESS, VERIFIER_ADDRESS, PAYMENT_TYPE_QUERY_FEE)
252+
assert.fieldEquals("ProvisionFeeCut", entityId, "feeCut", feeCut.toString())
253+
})
254+
})

0 commit comments

Comments
 (0)