diff --git a/ledger/common/address.go b/ledger/common/address.go index 6d626f42..3c66cca5 100644 --- a/ledger/common/address.go +++ b/ledger/common/address.go @@ -107,23 +107,40 @@ func NewAddressFromParts( paymentAddr []byte, stakingAddr []byte, ) (Address, error) { + // Validate network ID + if networkId != AddressNetworkTestnet && networkId != AddressNetworkMainnet { + return Address{}, errors.New("invalid network ID") + } + + // Handle stake-only addresses + if addrType == AddressTypeNoneKey || addrType == AddressTypeNoneScript { + if len(paymentAddr) > 0 { + return Address{}, errors.New("payment address must be empty for stake-only addresses") + } + if len(stakingAddr) != AddressHashSize { + return Address{}, fmt.Errorf("staking key must be exactly %d bytes", AddressHashSize) + } + return Address{ + addressType: addrType, + networkId: networkId, + stakingAddress: stakingAddr, + }, nil + } + + // Handle regular addresses if len(paymentAddr) != AddressHashSize { - return Address{}, fmt.Errorf( - "invalid payment address hash length: %d", - len(paymentAddr), - ) + return Address{}, fmt.Errorf("payment address must be exactly %d bytes", AddressHashSize) } + if len(stakingAddr) > 0 && len(stakingAddr) != AddressHashSize { - return Address{}, fmt.Errorf( - "invalid staking address hash length: %d", - len(stakingAddr), - ) + return Address{}, fmt.Errorf("staking address must be empty or exactly %d bytes", AddressHashSize) } + return Address{ addressType: addrType, networkId: networkId, - paymentAddress: paymentAddr[:], - stakingAddress: stakingAddr[:], + paymentAddress: paymentAddr, + stakingAddress: stakingAddr, }, nil } diff --git a/ledger/common/certs.go b/ledger/common/certs.go index 05b8a528..fb4d5f23 100644 --- a/ledger/common/certs.go +++ b/ledger/common/certs.go @@ -15,6 +15,8 @@ package common import ( + "encoding/hex" + "encoding/json" "errors" "fmt" "net" @@ -299,11 +301,11 @@ const ( ) type PoolRelay struct { - Type int - Port *uint32 - Ipv4 *net.IP - Ipv6 *net.IP - Hostname *string + Type int `json:"type"` + Port *uint32 `json:"port,omitempty"` + Ipv4 *net.IP `json:"ipv4,omitempty"` + Ipv6 *net.IP `json:"ipv6,omitempty"` + Hostname *string `json:"hostname,omitempty"` } func (p *PoolRelay) UnmarshalCBOR(data []byte) error { @@ -373,18 +375,121 @@ func (p *PoolRelay) Utxorpc() (*utxorpc.Relay, error) { } type PoolRegistrationCertificate struct { - cbor.StructAsArray - cbor.DecodeStoreCbor - CertType uint - Operator PoolKeyHash - VrfKeyHash VrfKeyHash - Pledge uint64 - Cost uint64 - Margin cbor.Rat - RewardAccount AddrKeyHash - PoolOwners []AddrKeyHash - Relays []PoolRelay - PoolMetadata *PoolMetadata + cbor.StructAsArray `json:"-"` + cbor.DecodeStoreCbor `json:"-"` + CertType uint `json:"certType,omitempty"` + Operator PoolKeyHash `json:"operator"` + VrfKeyHash VrfKeyHash `json:"vrfKeyHash"` + Pledge uint64 `json:"pledge"` + Cost uint64 `json:"cost"` + Margin GenesisRat `json:"margin"` + RewardAccount AddrKeyHash `json:"rewardAccount"` + PoolOwners []AddrKeyHash `json:"poolOwners"` + Relays []PoolRelay `json:"relays"` + PoolMetadata *PoolMetadata `json:"poolMetadata,omitempty"` +} + +func (p *PoolRegistrationCertificate) UnmarshalJSON(data []byte) error { + type tempPool struct { + Operator string `json:"operator"` + VrfKeyHash string `json:"vrfKeyHash"` + Pledge uint64 `json:"pledge"` + Cost uint64 `json:"cost"` + Margin json.RawMessage `json:"margin"` + RewardAccount json.RawMessage `json:"rewardAccount"` + PoolOwners []string `json:"poolOwners"` + Relays []struct { + Type int `json:"type"` + Port *uint32 `json:"port,omitempty"` + Ipv4 *net.IP `json:"ipv4,omitempty"` + Ipv6 *net.IP `json:"ipv6,omitempty"` + Hostname *string `json:"hostname,omitempty"` + } `json:"relays"` + PoolMetadata *PoolMetadata `json:"poolMetadata,omitempty"` + } + + var tmp tempPool + //nolint:musttag + if err := json.Unmarshal(data, &tmp); err != nil { + return fmt.Errorf("failed to unmarshal pool registration: %w", err) + } + + p.Pledge = tmp.Pledge + p.Cost = tmp.Cost + p.Relays = make([]PoolRelay, len(tmp.Relays)) + for i, relay := range tmp.Relays { + p.Relays[i] = PoolRelay{ + Type: relay.Type, + Port: relay.Port, + Ipv4: relay.Ipv4, + Ipv6: relay.Ipv6, + Hostname: relay.Hostname, + } + } + p.PoolMetadata = tmp.PoolMetadata + + // Handle margin field + if len(tmp.Margin) > 0 { + if err := p.Margin.UnmarshalJSON(tmp.Margin); err != nil { + return fmt.Errorf("failed to unmarshal margin: %w", err) + } + } + + // Handle reward account + if len(tmp.RewardAccount) > 0 { + var ra struct { + Credential struct { + KeyHash string `json:"key hash"` + } `json:"credential"` + } + if err := json.Unmarshal(tmp.RewardAccount, &ra); err != nil { + return fmt.Errorf("failed to unmarshal reward account: %w", err) + } + + if ra.Credential.KeyHash != "" { + hashBytes, err := hex.DecodeString(ra.Credential.KeyHash) + if err != nil { + return fmt.Errorf("failed to decode reward account key hash: %w", err) + } + if len(hashBytes) != AddressHashSize { + return fmt.Errorf("invalid key hash length: expected %d, got %d", AddressHashSize, len(hashBytes)) + } + p.RewardAccount = AddrKeyHash(NewBlake2b224(hashBytes)) + } + } + + // Convert operator key + if tmp.Operator != "" { + opBytes, err := hex.DecodeString(tmp.Operator) + if err != nil { + return fmt.Errorf("invalid operator key: %w", err) + } + p.Operator = PoolKeyHash(NewBlake2b224(opBytes)) + } + + // Convert VRF key hash + if tmp.VrfKeyHash != "" { + vrfBytes, err := hex.DecodeString(tmp.VrfKeyHash) + if err != nil { + return fmt.Errorf("invalid VRF key hash: %w", err) + } + p.VrfKeyHash = VrfKeyHash(NewBlake2b256(vrfBytes)) + } + + // Convert pool owners + if len(tmp.PoolOwners) > 0 { + owners := make([]AddrKeyHash, len(tmp.PoolOwners)) + for i, owner := range tmp.PoolOwners { + ownerBytes, err := hex.DecodeString(owner) + if err != nil { + return fmt.Errorf("invalid pool owner key: %w", err) + } + owners[i] = AddrKeyHash(NewBlake2b224(ownerBytes)) + } + p.PoolOwners = owners + } + + return nil } func (c PoolRegistrationCertificate) isCertificate() {} diff --git a/ledger/shelley/genesis.go b/ledger/shelley/genesis.go index 0841651d..dd3bb644 100644 --- a/ledger/shelley/genesis.go +++ b/ledger/shelley/genesis.go @@ -17,9 +17,11 @@ package shelley import ( "encoding/hex" "encoding/json" + "errors" "io" "math/big" "os" + "reflect" "time" "github.com/blinklabs-io/gouroboros/cbor" @@ -42,7 +44,12 @@ type ShelleyGenesis struct { ProtocolParameters ShelleyGenesisProtocolParams `json:"protocolParams"` GenDelegs map[string]map[string]string `json:"genDelegs"` InitialFunds map[string]uint64 `json:"initialFunds"` - Staking any `json:"staking"` + Staking GenesisStaking `json:"staking"` +} + +type GenesisStaking struct { + Pools map[string]common.PoolRegistrationCertificate `json:"pools"` + Stake map[string]string `json:"stake"` } func (g ShelleyGenesis) MarshalCBOR() ([]byte, error) { @@ -65,13 +72,46 @@ func (g ShelleyGenesis) MarshalCBOR() ([]byte, error) { cbor.NewByteString(vrfBytes), } } - staking := []any{} - if g.Staking == nil { - staking = []any{ - map[any]any{}, - map[any]any{}, + + // Convert pools to CBOR format + cborPools := make(map[cbor.ByteString]any) + for poolId, pool := range g.Staking.Pools { + poolIdBytes, err := hex.DecodeString(poolId) + if err != nil { + return nil, err + } + vrfBytes := pool.VrfKeyHash.Bytes() + rewardAccountBytes := pool.RewardAccount.Bytes() + cborPools[cbor.NewByteString(poolIdBytes)] = []any{ + pool.Cost, + pool.Margin, + pool.Pledge, + pool.Operator.Bytes(), + []any{ + []byte{0}, + rewardAccountBytes, + }, + convertAddrKeyHashesToBytes(pool.PoolOwners), + convertPoolRelays(pool.Relays), + vrfBytes, + pool.PoolMetadata, + } + } + + // Convert stake to CBOR format + cborStake := make(map[cbor.ByteString]cbor.ByteString) + for stakeAddr, poolId := range g.Staking.Stake { + stakeAddrBytes, err := hex.DecodeString(stakeAddr) + if err != nil { + return nil, err } + poolIdBytes, err := hex.DecodeString(poolId) + if err != nil { + return nil, err + } + cborStake[cbor.NewByteString(stakeAddrBytes)] = cbor.NewByteString(poolIdBytes) } + slotLengthMs := &big.Rat{} tmpData := []any{ []any{ @@ -95,11 +135,75 @@ func (g ShelleyGenesis) MarshalCBOR() ([]byte, error) { g.ProtocolParameters, genDelegs, g.InitialFunds, - staking, + []any{ + cborPools, + cborStake, + }, } return cbor.Encode(tmpData) } +func convertAddrKeyHashesToBytes(hashes []common.AddrKeyHash) [][]byte { + result := make([][]byte, len(hashes)) + for i, h := range hashes { + result[i] = h.Bytes() + } + return result +} + +func convertPoolRelays(relays []common.PoolRelay) []any { + result := make([]any, len(relays)) + for i, relay := range relays { + switch relay.Type { + case 0: // SingleHostAddr + var ipv4, ipv6 []byte + var port uint32 + if relay.Ipv4 != nil { + ipv4 = relay.Ipv4.To4() + } + if relay.Ipv6 != nil { + ipv6 = relay.Ipv6.To16() + } + if relay.Port != nil { + port = *relay.Port + } + result[i] = map[string]any{ + "single host addr": []any{ + ipv4, + ipv6, + port, + }, + } + case 1: // SingleHostName + var hostname string + var port uint32 + if relay.Hostname != nil { + hostname = *relay.Hostname + } + if relay.Port != nil { + port = *relay.Port + } + result[i] = map[string]any{ + "single host name": []any{ + hostname, + port, + }, + } + case 2: // MultiHostName + var hostname string + if relay.Hostname != nil { + hostname = *relay.Hostname + } + result[i] = map[string]any{ + "multi host name": hostname, + } + default: + result[i] = nil + } + } + return result +} + func (g *ShelleyGenesis) GenesisUtxos() ([]common.Utxo, error) { ret := []common.Utxo{} for address, amount := range g.InitialFunds { @@ -128,6 +232,128 @@ func (g *ShelleyGenesis) GenesisUtxos() ([]common.Utxo, error) { return ret, nil } +func (g *ShelleyGenesis) getNetworkId() (uint8, error) { + switch g.NetworkId { + case "Mainnet": + return common.AddressNetworkMainnet, nil + case "Testnet": + return common.AddressNetworkTestnet, nil + default: + return 0, errors.New("unknown network ID") + } +} + +func (g *ShelleyGenesis) InitialPools() (map[string]common.PoolRegistrationCertificate, map[string][]common.Address, error) { + pools := make(map[string]common.PoolRegistrationCertificate) + poolStake := make(map[string][]common.Address) + + if reflect.DeepEqual(g.Staking, GenesisStaking{}) { + return pools, poolStake, nil + } + + networkId, err := g.getNetworkId() + if err != nil { + return nil, nil, err + } + + // Process all stake addresses + for stakeAddr, poolId := range g.Staking.Stake { + stakeKey, err := hex.DecodeString(stakeAddr) + if err != nil { + return nil, nil, errors.New("failed to decode stake key") + } + + addr, err := common.NewAddressFromParts( + common.AddressTypeNoneScript, // Script stake address + networkId, + nil, + stakeKey, + ) + if err != nil { + return nil, nil, errors.New("failed to create address") + } + + poolStake[poolId] = append(poolStake[poolId], addr) + } + + // Process all stake pools + for poolId, pool := range g.Staking.Pools { + operatorBytes, err := hex.DecodeString(poolId) + if err != nil { + return nil, nil, errors.New("failed to decode pool ID") + } + + pools[poolId] = common.PoolRegistrationCertificate{ + Operator: common.Blake2b224(operatorBytes), + VrfKeyHash: pool.VrfKeyHash, + Pledge: pool.Pledge, + Cost: pool.Cost, + Margin: pool.Margin, + RewardAccount: pool.RewardAccount, + PoolOwners: pool.PoolOwners, + Relays: pool.Relays, + PoolMetadata: pool.PoolMetadata, + } + } + + return pools, poolStake, nil +} + +func (g *ShelleyGenesis) PoolById(poolId string) (*common.PoolRegistrationCertificate, []common.Address, error) { + if len(poolId) != 56 { + return nil, nil, errors.New("invalid pool ID length") + } + + pool, exists := g.Staking.Pools[poolId] + if !exists { + return nil, nil, errors.New("pool not found") + } + + networkId, err := g.getNetworkId() + if err != nil { + return nil, nil, err + } + + var delegators []common.Address + for stakeAddr, pId := range g.Staking.Stake { + if pId == poolId { + stakeKey, err := hex.DecodeString(stakeAddr) + if err != nil { + return nil, nil, errors.New("failed to decode stake key") + } + + addr, err := common.NewAddressFromParts( + common.AddressTypeNoneScript, + networkId, + nil, + stakeKey, + ) + if err != nil { + return nil, nil, errors.New("failed to create address") + } + + delegators = append(delegators, addr) + } + } + + operatorBytes, err := hex.DecodeString(poolId) + if err != nil { + return nil, nil, errors.New("failed to decode pool operator key") + } + + return &common.PoolRegistrationCertificate{ + Operator: common.Blake2b224(operatorBytes), + VrfKeyHash: pool.VrfKeyHash, + Pledge: pool.Pledge, + Cost: pool.Cost, + Margin: pool.Margin, + RewardAccount: pool.RewardAccount, + PoolOwners: pool.PoolOwners, + Relays: pool.Relays, + PoolMetadata: pool.PoolMetadata, + }, delegators, nil +} + type ShelleyGenesisProtocolParams struct { cbor.StructAsArray MinFeeA uint `json:"minFeeA"` diff --git a/ledger/shelley/genesis_test.go b/ledger/shelley/genesis_test.go index 35926576..05dfa588 100644 --- a/ledger/shelley/genesis_test.go +++ b/ledger/shelley/genesis_test.go @@ -15,6 +15,7 @@ package shelley_test import ( + "encoding/hex" "encoding/json" "math/big" "reflect" @@ -96,6 +97,9 @@ const shelleyGenesisConfig = ` "securityParam": 2160 } ` +const ( + expectedTestnetScriptStakeHeader = 0xF1 +) var expectedGenesisObj = shelley.ShelleyGenesis{ SystemStart: time.Date( @@ -250,3 +254,144 @@ func TestGenesisUtxos(t *testing.T) { ) } } + +func TestGenesisStaking(t *testing.T) { + const testGenesis = `{ + "systemStart": "2017-09-23T21:44:51Z", + "networkMagic": 764824073, + "networkId": "Testnet", + "staking": { + "pools": { + "0aedc455785463235311c990f68742c9043cd79af09ab31c2ba5e195": { + "cost": 340000000, + "margin": 0.0, + "pledge": 0, + "publicKey": "0aedc455785463235311c990f68742c9043cd79af09ab31c2ba5e195", + "vrf": "eb53a17fbad9b7ea0bcf1e1ea89355305600d593b426dfc3084a924d8877d47e", + "rewardAccount": { + "credential": { + "key hash": "6079cde665c2035b8d9ac8929307bdd7f20a51e678e9d4a5e39ace3a" + }, + "network": "Testnet" + } + } + }, + "stake": { + "24632b71152f31516054075897d0d4ababc33204f8a8661136d49e36": "0aedc455785463235311c990f68742c9043cd79af09ab31c2ba5e195" + } + }, + "protocolParams": { + "minFeeA": 44, + "minFeeB": 155381, + "maxBlockBodySize": 65536, + "maxTxSize": 16384, + "maxBlockHeaderSize": 1100, + "keyDeposit": 2000000, + "poolDeposit": 500000000 + } + }` + + t.Run("TestInitialPools", func(t *testing.T) { + genesis, err := shelley.NewShelleyGenesisFromReader(strings.NewReader(testGenesis)) + if err != nil { + t.Fatalf("Genesis parsing failed: %v", err) + } + + pools, delegators, err := genesis.InitialPools() + if err != nil { + t.Fatalf("InitialPools failed: %v", err) + } + + expectedPoolId := "0aedc455785463235311c990f68742c9043cd79af09ab31c2ba5e195" + expectedStakeKey := "24632b71152f31516054075897d0d4ababc33204f8a8661136d49e36" + + // Test pool count + if len(pools) != 1 { + t.Errorf("Expected 1 pool, got %d", len(pools)) + } + + // Test pool data + pool, exists := pools[expectedPoolId] + if !exists { + t.Fatal("Expected pool not found") + } + + if pool.Cost != 340000000 { + t.Errorf("Expected pool cost 340000000, got %d", pool.Cost) + } + + // Test delegators + if len(delegators) != 1 { + t.Errorf("Expected 1 delegator mapping, got %d", len(delegators)) + } + + delegs := delegators[expectedPoolId] + if len(delegs) != 1 { + t.Errorf("Expected 1 delegator, got %d", len(delegs)) + } else { + // Verify address format + // Verify address type and network + if delegs[0].NetworkId() != common.AddressNetworkTestnet { + t.Errorf("Expected testnet address, got network ID %d", delegs[0].NetworkId()) + } + + if delegs[0].Type() != common.AddressTypeNoneScript { + t.Errorf("Expected script stake address type, got %d", delegs[0].Type()) + } + + // Verify stake key matches + stakeKeyHash := delegs[0].StakeKeyHash() + if hex.EncodeToString(stakeKeyHash[:]) != expectedStakeKey { + t.Errorf("Delegator key mismatch:\nExpected: %s\nActual: %s", + expectedStakeKey, hex.EncodeToString(stakeKeyHash[:])) + } + } + }) + + t.Run("TestPoolById", func(t *testing.T) { + genesis, err := shelley.NewShelleyGenesisFromReader(strings.NewReader(testGenesis)) + if err != nil { + t.Fatalf("Genesis parsing failed: %v", err) + } + + expectedPoolId := "0aedc455785463235311c990f68742c9043cd79af09ab31c2ba5e195" + expectedStakeKey := "24632b71152f31516054075897d0d4ababc33204f8a8661136d49e36" + + pool, delegators, err := genesis.PoolById(expectedPoolId) + if err != nil { + t.Fatalf("PoolById failed: %v", err) + } + + // Test pool data + if pool.Cost != 340000000 { + t.Errorf("Expected pool cost 340000000, got %d", pool.Cost) + } + + // Test delegators + if len(delegators) != 1 { + t.Errorf("Expected 1 delegator, got %d", len(delegators)) + } else { + // Verify address type and network + if delegators[0].NetworkId() != common.AddressNetworkTestnet { + t.Errorf("Expected testnet address, got network ID %d", delegators[0].NetworkId()) + } + + if delegators[0].Type() != common.AddressTypeNoneScript { + t.Errorf("Expected script stake address type, got %d", delegators[0].Type()) + } + + // Verify stake key matches + stakeKeyHash := delegators[0].StakeKeyHash() + if hex.EncodeToString(stakeKeyHash[:]) != expectedStakeKey { + t.Errorf("Delegator key mismatch:\nExpected: %s\nActual: %s", + expectedStakeKey, hex.EncodeToString(stakeKeyHash[:])) + } + } + + // Test non-existent pool + _, _, err = genesis.PoolById("nonexistentpoolid") + if err == nil { + t.Error("Expected error for non-existent pool, got nil") + } + }) +}