Skip to content

Commit 78b62b8

Browse files
committed
feat: filter governance events by address
Signed-off-by: Chris Gianelloni <wolf31o2@blinklabs.io>
1 parent 612b47b commit 78b62b8

File tree

2 files changed

+248
-2
lines changed

2 files changed

+248
-2
lines changed

filter/cardano/cardano.go

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ package cardano
1616

1717
import (
1818
"bytes"
19+
"encoding/hex"
1920
"sync"
2021

2122
"github.com/blinklabs-io/adder/event"
@@ -196,6 +197,57 @@ func (c *Cardano) matchAddressFilter(te event.TransactionEvent) bool {
196197
return false
197198
}
198199

200+
// matchAddressFilterGovernance checks if governance event matches address filters
201+
func (c *Cardano) matchAddressFilterGovernance(ge event.GovernanceEvent) bool {
202+
// Check proposal procedures for reward account matches
203+
for _, prop := range ge.ProposalProcedures {
204+
// RewardAccount is a stake/reward address string
205+
if _, exists := c.filterSet.addresses.stakeAddresses[prop.RewardAccount]; exists {
206+
return true
207+
}
208+
209+
// Check treasury withdrawal addresses if this is a treasury withdrawal action
210+
if prop.ActionData.TreasuryWithdrawal != nil {
211+
for _, withdrawal := range prop.ActionData.TreasuryWithdrawal.Withdrawals {
212+
// Check against payment addresses
213+
if _, exists := c.filterSet.addresses.paymentAddresses[withdrawal.Address]; exists {
214+
return true
215+
}
216+
// Also check against stake addresses (some withdrawals may use stake addresses)
217+
if _, exists := c.filterSet.addresses.stakeAddresses[withdrawal.Address]; exists {
218+
return true
219+
}
220+
}
221+
}
222+
}
223+
224+
// Check vote delegation certificates for stake credential matches
225+
if len(c.filterSet.addresses.stakeCredentialHashes) > 0 {
226+
for _, cert := range ge.VoteDelegationCertificates {
227+
// StakeCredential is a hex string of the credential hash (28 bytes)
228+
credBytes, err := hex.DecodeString(cert.StakeCredential)
229+
if err != nil {
230+
continue
231+
}
232+
for _, filterHash := range c.filterSet.addresses.stakeCredentialHashes {
233+
// filterHash may include header byte from bech32 decoding
234+
// Compare against last 28 bytes (the actual credential hash)
235+
var hashToCompare []byte
236+
if len(filterHash) > 28 {
237+
hashToCompare = filterHash[len(filterHash)-28:]
238+
} else {
239+
hashToCompare = filterHash
240+
}
241+
if bytes.Equal(credBytes, hashToCompare) {
242+
return true
243+
}
244+
}
245+
}
246+
}
247+
248+
return false
249+
}
250+
199251
// matchStakeCertificates checks certificates against stake credential hashes
200252
func (c *Cardano) matchStakeCertificates(certificates []ledger.Certificate) bool {
201253
for _, certificate := range certificates {
@@ -213,7 +265,15 @@ func (c *Cardano) matchStakeCertificates(certificates []ledger.Certificate) bool
213265

214266
// Use pre-decoded stake credential hashes with bytes.Equal comparison
215267
for _, filterHash := range c.filterSet.addresses.stakeCredentialHashes {
216-
if bytes.Equal(credBytes, filterHash) {
268+
// filterHash may include header byte from bech32 decoding
269+
// Compare against last 28 bytes (the actual credential hash)
270+
var hashToCompare []byte
271+
if len(filterHash) > 28 {
272+
hashToCompare = filterHash[len(filterHash)-28:]
273+
} else {
274+
hashToCompare = filterHash
275+
}
276+
if bytes.Equal(credBytes, hashToCompare) {
217277
return true
218278
}
219279
}
@@ -262,13 +322,20 @@ func (c *Cardano) matchAssetFilter(te event.TransactionEvent) bool {
262322

263323
// filterGovernanceEvent checks all applicable filters for governance events
264324
func (c *Cardano) filterGovernanceEvent(ge event.GovernanceEvent) bool {
325+
// Check address filter
326+
if c.filterSet.hasAddressFilter {
327+
if !c.matchAddressFilterGovernance(ge) {
328+
return false
329+
}
330+
}
331+
265332
// Check DRep filter
266333
if c.filterSet.hasDRepFilter {
267334
if !c.matchDRepFilterGovernance(ge) {
268335
return false
269336
}
270337
}
271-
// Future: pool filter, address filter for governance
338+
272339
return true
273340
}
274341

filter/cardano/cardano_test.go

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -774,3 +774,182 @@ func TestFilterByPoolId(t *testing.T) {
774774
t.Fatal("Test timed out waiting for filtered event")
775775
}
776776
}
777+
778+
func TestFilterByAddressGovernanceEvent(t *testing.T) {
779+
// Use a stake/reward address format for proposal reward accounts
780+
stakeAddr := "stake1uyehkck0lajq8gr28t9uxnuvgcqrc6070x3k9r8048z8y5gh6ffgw"
781+
// 28 bytes = 56 hex chars for stake credential hash
782+
stakeCredHex := "abcd1234567890abcdef1234567890abcdef1234567890abcdef1234"
783+
784+
t.Run("matches proposal reward account", func(t *testing.T) {
785+
cs := New(WithAddresses([]string{stakeAddr}))
786+
787+
evt := event.Event{
788+
Payload: event.GovernanceEvent{
789+
ProposalProcedures: []event.ProposalProcedureData{
790+
{
791+
Index: 0,
792+
Deposit: 100000000,
793+
RewardAccount: stakeAddr,
794+
ActionType: "Info",
795+
},
796+
},
797+
},
798+
}
799+
800+
err := cs.Start()
801+
assert.NoError(t, err)
802+
defer cs.Stop()
803+
804+
cs.InputChan() <- evt
805+
806+
select {
807+
case filteredEvt := <-cs.OutputChan():
808+
assert.Equal(t, evt, filteredEvt)
809+
case <-time.After(1 * time.Second):
810+
t.Error("Expected event to pass filter but it didn't")
811+
}
812+
})
813+
814+
t.Run("matches treasury withdrawal address", func(t *testing.T) {
815+
// Use a payment address for treasury withdrawal
816+
paymentAddr := "addr1qx2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp"
817+
cs := New(WithAddresses([]string{paymentAddr}))
818+
819+
evt := event.Event{
820+
Payload: event.GovernanceEvent{
821+
ProposalProcedures: []event.ProposalProcedureData{
822+
{
823+
Index: 0,
824+
Deposit: 100000000000,
825+
RewardAccount: "stake1...", // Different from filter
826+
ActionType: "TreasuryWithdrawal",
827+
ActionData: event.GovActionData{
828+
TreasuryWithdrawal: &event.TreasuryWithdrawalActionData{
829+
Withdrawals: []event.TreasuryWithdrawalItem{
830+
{
831+
Address: paymentAddr,
832+
Amount: 50000000000,
833+
},
834+
},
835+
},
836+
},
837+
},
838+
},
839+
},
840+
}
841+
842+
err := cs.Start()
843+
assert.NoError(t, err)
844+
defer cs.Stop()
845+
846+
cs.InputChan() <- evt
847+
848+
select {
849+
case filteredEvt := <-cs.OutputChan():
850+
assert.Equal(t, evt, filteredEvt)
851+
case <-time.After(1 * time.Second):
852+
t.Error("Expected event to pass filter but it didn't")
853+
}
854+
})
855+
856+
t.Run("matches vote delegation stake credential", func(t *testing.T) {
857+
// Create a stake address that decodes to a credential hash
858+
realStakeAddr := "stake1uyehkck0lajq8gr28t9uxnuvgcqrc6070x3k9r8048z8y5gh6ffgw"
859+
cs := New(WithAddresses([]string{realStakeAddr}))
860+
861+
// Decode the stake address to get the actual credential hash
862+
_, data, err := bech32.DecodeNoLimit(realStakeAddr)
863+
assert.NoError(t, err)
864+
converted, err := bech32.ConvertBits(data, 5, 8, false)
865+
assert.NoError(t, err)
866+
// Skip the first byte (header) to get the credential hash
867+
credHash := hex.EncodeToString(converted[1:])
868+
869+
evt := event.Event{
870+
Payload: event.GovernanceEvent{
871+
VoteDelegationCertificates: []event.VoteDelegationCertificateData{
872+
{
873+
CertificateType: "VoteDelegation",
874+
StakeCredential: credHash,
875+
DRepType: "KeyHash",
876+
DRepHash: "someDRepHash",
877+
},
878+
},
879+
},
880+
}
881+
882+
err = cs.Start()
883+
assert.NoError(t, err)
884+
defer cs.Stop()
885+
886+
cs.InputChan() <- evt
887+
888+
select {
889+
case filteredEvt := <-cs.OutputChan():
890+
assert.Equal(t, evt, filteredEvt)
891+
case <-time.After(1 * time.Second):
892+
t.Error("Expected event to pass filter but it didn't")
893+
}
894+
})
895+
896+
t.Run("does not match when address not in filter", func(t *testing.T) {
897+
cs := New(WithAddresses([]string{stakeAddr}))
898+
899+
otherStakeAddr := "stake1uxxx..."
900+
evt := event.Event{
901+
Payload: event.GovernanceEvent{
902+
ProposalProcedures: []event.ProposalProcedureData{
903+
{
904+
Index: 0,
905+
RewardAccount: otherStakeAddr,
906+
ActionType: "Info",
907+
},
908+
},
909+
},
910+
}
911+
912+
err := cs.Start()
913+
assert.NoError(t, err)
914+
defer cs.Stop()
915+
916+
cs.InputChan() <- evt
917+
918+
select {
919+
case <-cs.OutputChan():
920+
t.Error("Expected event to be filtered out but it passed through")
921+
case <-time.After(100 * time.Millisecond):
922+
// Expected no event
923+
}
924+
})
925+
926+
t.Run("does not match governance event with no address data", func(t *testing.T) {
927+
cs := New(WithAddresses([]string{stakeAddr}))
928+
929+
evt := event.Event{
930+
Payload: event.GovernanceEvent{
931+
// Only voting procedures, no addresses
932+
VotingProcedures: []event.VotingProcedureData{
933+
{
934+
VoterType: "DRep",
935+
VoterHash: stakeCredHex,
936+
Vote: "Yes",
937+
},
938+
},
939+
},
940+
}
941+
942+
err := cs.Start()
943+
assert.NoError(t, err)
944+
defer cs.Stop()
945+
946+
cs.InputChan() <- evt
947+
948+
select {
949+
case <-cs.OutputChan():
950+
t.Error("Expected event to be filtered out but it passed through")
951+
case <-time.After(100 * time.Millisecond):
952+
// Expected no event
953+
}
954+
})
955+
}

0 commit comments

Comments
 (0)