diff --git a/internal/config/config.go b/internal/config/config.go index 3ad17f8..68c80ac 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -91,8 +91,9 @@ var globalConfig = &Config{ Directory: "./.state", }, Profiles: []string{ + // NOTE: this is here because .ada wasn't added to the discovery address when it was originally deployed "ada-preprod", - "hydra-preprod", + "auto-preprod", }, } diff --git a/internal/config/profile.go b/internal/config/profile.go index fd035a5..41c5beb 100644 --- a/internal/config/profile.go +++ b/internal/config/profile.go @@ -1,4 +1,4 @@ -// Copyright 2023 Blink Labs Software +// Copyright 2024 Blink Labs Software // // Use of this source code is governed by an MIT-style // license that can be found in the LICENSE file or at @@ -7,12 +7,13 @@ package config type Profile struct { - Network string // Cardano network name - Tld string // Top-level domain - PolicyId string // Verification asset policy ID - ScriptAddress string // Address to follow - InterceptSlot uint64 // Chain-sync initial intercept slot - InterceptHash string // Chain-sync initial intercept hash + Network string // Cardano network name + Tld string // Top-level domain + PolicyId string // Verification asset policy ID + ScriptAddress string // Address to follow + InterceptSlot uint64 // Chain-sync initial intercept slot + InterceptHash string // Chain-sync initial intercept hash + DiscoveryAddress string // Auto-discovery address to follow } func GetProfiles() []Profile { @@ -65,4 +66,12 @@ var Profiles = map[string]Profile{ InterceptSlot: 67799029, InterceptHash: "4815dae9cd8f492ab51b109ba87d091ae85a0999af33ac459d8504122cb911f7", }, + "auto-preprod": Profile{ + Network: "preprod", + PolicyId: "63cdaef8b84702282c3454ae130ada94a9b200e32be21abd47fc636b", + DiscoveryAddress: "addr_test1xrhqrug2hnc9az4ru02kp9rlfcppl464gl4yc8s8jm5p8kygc3uvcfh3r3kaa5gyk5l2vgdl8vj8cstslf4w2ajuy0wsp5fm89", + // The intercept slot/hash correspond to the block before the first TX on the above address + InterceptSlot: 67778432, + InterceptHash: "6db5cdcfa1ee9cc137b0b238ff9251d4481c23bf49ad6272cb833b034a003cbe", + }, } diff --git a/internal/indexer/datum.go b/internal/indexer/datum.go new file mode 100644 index 0000000..fd24c33 --- /dev/null +++ b/internal/indexer/datum.go @@ -0,0 +1,47 @@ +// Copyright 2024 Blink Labs Software +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +package indexer + +import ( + "fmt" + + "github.com/blinklabs-io/gouroboros/cbor" +) + +type DNSReferenceRefScriptDatum struct { + // This allows the type to be used with cbor.DecodeGeneric + cbor.StructAsArray + TldName []byte + SymbolDrat []byte + SymbolHns []byte +} + +func (d *DNSReferenceRefScriptDatum) UnmarshalCBOR(cborData []byte) error { + var tmpData cbor.Constructor + if _, err := cbor.Decode(cborData, &tmpData); err != nil { + return err + } + if tmpData.Constructor() != 3 { + return fmt.Errorf("unexpected outer constructor index: %d", tmpData.Constructor()) + } + tmpDataFields := tmpData.Fields() + if len(tmpDataFields) != 1 { + return fmt.Errorf("unexpected inner field count: expected 1, got %d", len(tmpDataFields)) + } + fieldInner, ok := tmpDataFields[0].(cbor.Constructor) + if !ok { + return fmt.Errorf("unexpected data type %T for outer constructor field", tmpDataFields[0]) + } + var tmpDataInner cbor.Constructor + if _, err := cbor.Decode(fieldInner.Cbor(), &tmpDataInner); err != nil { + return err + } + if tmpDataInner.Constructor() != 1 { + return fmt.Errorf("unexpected inner constructor index: %d", tmpDataInner.Constructor()) + } + return cbor.DecodeGeneric(tmpDataInner.FieldsCbor(), d) +} diff --git a/internal/indexer/indexer.go b/internal/indexer/indexer.go index 921ae05..f466b59 100644 --- a/internal/indexer/indexer.go +++ b/internal/indexer/indexer.go @@ -16,15 +16,16 @@ import ( "github.com/blinklabs-io/cdnsd/internal/config" "github.com/blinklabs-io/cdnsd/internal/state" + ouroboros "github.com/blinklabs-io/gouroboros" "github.com/blinklabs-io/adder/event" - filter_chainsync "github.com/blinklabs-io/adder/filter/chainsync" filter_event "github.com/blinklabs-io/adder/filter/event" input_chainsync "github.com/blinklabs-io/adder/input/chainsync" output_embedded "github.com/blinklabs-io/adder/output/embedded" "github.com/blinklabs-io/adder/pipeline" models "github.com/blinklabs-io/cardano-models" "github.com/blinklabs-io/gouroboros/cbor" + "github.com/blinklabs-io/gouroboros/ledger" ocommon "github.com/blinklabs-io/gouroboros/protocol/common" "github.com/miekg/dns" ) @@ -44,6 +45,14 @@ type Indexer struct { tipReached bool syncLogTimer *time.Timer syncStatus input_chainsync.ChainSyncStatus + watched []watchedAddr +} + +type watchedAddr struct { + Address string + Tld string + PolicyId string + Discovery bool } // Singleton indexer instance @@ -53,6 +62,43 @@ var globalIndexer = &Indexer{ func (i *Indexer) Start() error { cfg := config.GetConfig() + for _, profile := range config.GetProfiles() { + if profile.ScriptAddress != "" { + i.watched = append( + i.watched, + watchedAddr{ + Address: profile.ScriptAddress, + Tld: profile.Tld, + PolicyId: profile.PolicyId, + }, + ) + } + if profile.DiscoveryAddress != "" { + i.watched = append( + i.watched, + watchedAddr{ + Address: profile.DiscoveryAddress, + PolicyId: profile.PolicyId, + Discovery: true, + }, + ) + } + } + // Load discovered TLD/scripts from state + discoveredAddr, err := state.GetState().GetDiscoveredAddresses() + if err != nil { + return err + } + for _, tmpAddr := range discoveredAddr { + i.watched = append( + i.watched, + watchedAddr{ + Address: tmpAddr.Address, + PolicyId: tmpAddr.PolicyId, + Tld: tmpAddr.TldName, + }, + ) + } // Create pipeline i.pipeline = pipeline.New() // Configure pipeline input @@ -146,15 +192,6 @@ func (i *Indexer) Start() error { filter_event.WithTypes([]string{"chainsync.transaction"}), ) i.pipeline.AddFilter(filterEvent) - // We only care about transactions on a certain address - var filterAddresses []string - for _, profile := range config.GetProfiles() { - filterAddresses = append(filterAddresses, profile.ScriptAddress) - } - filterChainsync := filter_chainsync.New( - filter_chainsync.WithAddresses(filterAddresses), - ) - i.pipeline.AddFilter(filterChainsync) // Configure pipeline output output := output_embedded.New( output_embedded.WithCallbackFunc(i.handleEvent), @@ -183,124 +220,245 @@ func (i *Indexer) Start() error { } func (i *Indexer) handleEvent(evt event.Event) error { - cfg := config.GetConfig() eventTx := evt.Payload.(input_chainsync.TransactionEvent) eventCtx := evt.Context.(input_chainsync.TransactionContext) for _, txOutput := range eventTx.Outputs { - for _, profile := range config.GetProfiles() { - if txOutput.Address().String() != profile.ScriptAddress { - continue - } - datum := txOutput.Datum() - if datum != nil { - var dnsDomain models.CardanoDnsDomain - if _, err := cbor.Decode(datum.Cbor(), &dnsDomain); err != nil { - slog.Warn( - fmt.Sprintf( - "error decoding TX (%s) output datum: %s", - eventCtx.TransactionHash, - err, - ), - ) - // Stop processing TX output if we can't parse the datum - continue + // Full address + outAddr := txOutput.Address() + // Only the payment portion of the address + // This is useful for comparing to generated script addresses + outAddrPayment := outAddr.PaymentAddress() + if outAddrPayment == nil { + continue + } + for _, watchedAddr := range i.watched { + if watchedAddr.Discovery { + if outAddr.String() == watchedAddr.Address || outAddrPayment.String() == watchedAddr.Address { + if err := i.handleEventOutputDiscovery(eventCtx, watchedAddr.PolicyId, txOutput); err != nil { + return err + } + break } - origin := string(dnsDomain.Origin) - // Convert origin to canonical form for consistency - // This mostly means adding a trailing period if it doesn't have one - domainName := dns.CanonicalName(origin) - // We want an empty value for the TLD root for convenience - if domainName == `.` { - domainName = `` + } else { + if outAddr.String() == watchedAddr.Address || outAddrPayment.String() == watchedAddr.Address { + if err := i.handleEventOutputDns(eventCtx, watchedAddr.Tld, watchedAddr.PolicyId, txOutput); err != nil { + return err + } + break } - // Append TLD - domainName = dns.CanonicalName( - domainName + profile.Tld, + } + } + } + return nil +} + +func (i *Indexer) handleEventOutputDns(eventCtx input_chainsync.TransactionContext, tldName string, policyId string, txOutput ledger.TransactionOutput) error { + cfg := config.GetConfig() + datum := txOutput.Datum() + if datum != nil { + var dnsDomain models.CardanoDnsDomain + if _, err := cbor.Decode(datum.Cbor(), &dnsDomain); err != nil { + slog.Debug( + fmt.Sprintf( + "error decoding TX (%s) output datum as CardanoDnsDomain: %s", + eventCtx.TransactionHash, + err, + ), + ) + // Stop processing TX output if we can't parse the datum + return nil + } + origin := string(dnsDomain.Origin) + // Convert origin to canonical form for consistency + // This mostly means adding a trailing period if it doesn't have one + domainName := dns.CanonicalName(origin) + // We want an empty value for the TLD root for convenience + if domainName == `.` { + domainName = `` + } + // Append TLD + domainName = dns.CanonicalName( + domainName + tldName, + ) + if cfg.Indexer.Verify { + // Look for asset matching domain origin and TLD policy ID + if txOutput.Assets() == nil { + slog.Warn( + fmt.Sprintf( + "ignoring datum for domain %q with no matching asset", + domainName, + ), ) - if cfg.Indexer.Verify { - // Look for asset matching domain origin and TLD policy ID - if txOutput.Assets() == nil { - slog.Warn( - fmt.Sprintf( - "ignoring datum for domain %q with no matching asset", - domainName, - ), - ) - continue - } - foundAsset := false - for _, policyId := range txOutput.Assets().Policies() { - for _, assetName := range txOutput.Assets().Assets(policyId) { - if policyId.String() == profile.PolicyId { - if string(assetName) == string(origin) { - foundAsset = true - } else { - slog.Warn( - fmt.Sprintf( - "ignoring datum for domain %q with no matching asset", - domainName, - ), - ) - } - } else { - slog.Warn( - fmt.Sprintf( - "ignoring datum for domain %q with no matching asset", - domainName, - ), - ) - } - } - } - if !foundAsset { - continue - } - // Make sure all records are for specified origin domain - badRecordName := false - for _, record := range dnsDomain.Records { - recordName := dns.CanonicalName( - string(record.Lhs), - ) - if !strings.HasSuffix(recordName, domainName) { + return nil + } + foundAsset := false + for _, tmpPolicyId := range txOutput.Assets().Policies() { + for _, assetName := range txOutput.Assets().Assets(tmpPolicyId) { + if tmpPolicyId.String() == policyId { + if string(assetName) == string(origin) { + foundAsset = true + break + } else { slog.Warn( fmt.Sprintf( - "ignoring datum with record %q outside of origin domain (%s)", - recordName, + "ignoring datum for domain %q with no matching asset", domainName, ), ) - badRecordName = true - break } + } else { + slog.Warn( + fmt.Sprintf( + "ignoring datum for domain %q with no matching asset", + domainName, + ), + ) } - if badRecordName { - continue - } - } - // Convert domain records into our storage format - tmpRecords := []state.DomainRecord{} - for _, record := range dnsDomain.Records { - tmpRecord := state.DomainRecord{ - Lhs: string(record.Lhs), - Type: string(record.Type), - Rhs: string(record.Rhs), - } - if record.Ttl.HasValue() { - tmpRecord.Ttl = int(record.Ttl.Value) - } - tmpRecords = append(tmpRecords, tmpRecord) } - if err := state.GetState().UpdateDomain(domainName, tmpRecords); err != nil { - return err + if foundAsset { + break } - slog.Info( - fmt.Sprintf( - "found updated registration for domain: %s", - domainName, - ), + } + if !foundAsset { + return nil + } + // Make sure all records are for specified origin domain + badRecordName := false + for _, record := range dnsDomain.Records { + recordName := dns.CanonicalName( + string(record.Lhs), ) + if !strings.HasSuffix(recordName, domainName) { + slog.Warn( + fmt.Sprintf( + "ignoring datum with record %q outside of origin domain (%s)", + recordName, + domainName, + ), + ) + badRecordName = true + break + } + } + if badRecordName { + return nil + } + } + // Convert domain records into our storage format + tmpRecords := []state.DomainRecord{} + for _, record := range dnsDomain.Records { + tmpRecord := state.DomainRecord{ + Lhs: string(record.Lhs), + Type: string(record.Type), + Rhs: string(record.Rhs), + } + if record.Ttl.HasValue() { + tmpRecord.Ttl = int(record.Ttl.Value) + } + tmpRecords = append(tmpRecords, tmpRecord) + } + if err := state.GetState().UpdateDomain(domainName, tmpRecords); err != nil { + return err + } + slog.Info( + fmt.Sprintf( + "found updated registration for domain: %s", + domainName, + ), + ) + } + return nil +} + +func (i *Indexer) handleEventOutputDiscovery(eventCtx input_chainsync.TransactionContext, policyId string, txOutput ledger.TransactionOutput) error { + cfg := config.GetConfig() + datum := txOutput.Datum() + if datum != nil { + var scriptRef DNSReferenceRefScriptDatum + if _, err := cbor.Decode(datum.Cbor(), &scriptRef); err != nil { + slog.Debug( + fmt.Sprintf( + "error decoding TX (%s) output datum as DNSReferenceRefScriptDatum: %s", + eventCtx.TransactionHash, + err, + ), + ) + // Stop processing TX output if we can't parse the datum + return nil + } + // Look for asset matching policy ID + var assetName []byte + if txOutput.Assets() == nil { + slog.Warn( + fmt.Sprintf( + "ignoring datum for DNS script for domain %q with no matching asset", + scriptRef.TldName, + ), + ) + return nil + } + for _, tmpPolicyId := range txOutput.Assets().Policies() { + for _, tmpAssetName := range txOutput.Assets().Assets(tmpPolicyId) { + if tmpPolicyId.String() == policyId { + assetName = tmpAssetName + break + } } } + if assetName == nil { + slog.Warn( + fmt.Sprintf( + "ignoring datum for DNS script for domain %q with no matching asset", + scriptRef.TldName, + ), + ) + return nil + } + // Add new TLD to watched addresses + network := ouroboros.NetworkByName(cfg.Indexer.Network) + if network == ouroboros.NetworkInvalid { + return fmt.Errorf("unknown named network: %s", cfg.Indexer.Network) + } + scriptAddr, err := ledger.NewAddressFromParts( + ledger.AddressTypeScriptNone, + network.Id, + assetName, + nil, + ) + if err != nil { + return err + } + i.watched = append( + i.watched, + watchedAddr{ + Tld: strings.TrimPrefix( + string(scriptRef.TldName), + `.`, + ), + PolicyId: hex.EncodeToString(scriptRef.SymbolDrat), + Address: scriptAddr.String(), + }, + ) + // Add to state + err = state.GetState().AddDiscoveredAddress( + state.DiscoveredAddress{ + Address: scriptAddr.String(), + PolicyId: hex.EncodeToString(scriptRef.SymbolDrat), + TldName: strings.TrimPrefix( + string(scriptRef.TldName), + `.`, + ), + }, + ) + if err != nil { + return err + } + slog.Info( + fmt.Sprintf( + "found new TLD: %s", + scriptRef.TldName, + ), + ) } return nil } diff --git a/internal/state/state.go b/internal/state/state.go index 79eb560..edbaf73 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -9,6 +9,7 @@ package state import ( "bytes" "encoding/gob" + "encoding/json" "errors" "fmt" "log/slog" @@ -23,6 +24,7 @@ import ( const ( chainsyncCursorKey = "chainsync_cursor" + discoveredAddrKey = "discovered_addresses" fingerprintKey = "config_fingerprint" ) @@ -38,6 +40,12 @@ type DomainRecord struct { Rhs string } +type DiscoveredAddress struct { + Address string + TldName string + PolicyId string +} + var globalState = &State{} func (s *State) Load() error { @@ -160,6 +168,51 @@ func (s *State) GetCursor() (uint64, string, error) { return slotNumber, blockHash, err } +func (s *State) AddDiscoveredAddress(addr DiscoveredAddress) error { + tmpAddrs, err := s.GetDiscoveredAddresses() + if err != nil { + return err + } + tmpAddrs = append(tmpAddrs, addr) + tmpAddrsJson, err := json.Marshal(&tmpAddrs) + if err != nil { + return err + } + err = s.db.Update(func(txn *badger.Txn) error { + return txn.Set( + []byte(discoveredAddrKey), + tmpAddrsJson, + ) + }) + if err != nil { + return err + } + return nil +} + +func (s *State) GetDiscoveredAddresses() ([]DiscoveredAddress, error) { + var ret []DiscoveredAddress + err := s.db.View(func(txn *badger.Txn) error { + item, err := txn.Get([]byte(discoveredAddrKey)) + if err != nil { + return err + } + err = item.Value(func(v []byte) error { + return json.Unmarshal(v, &ret) + }) + if err != nil { + return err + } + return nil + }) + if err != nil { + if err != badger.ErrKeyNotFound { + return ret, err + } + } + return ret, nil +} + func (s *State) UpdateDomain( domainName string, records []DomainRecord,