480 lines
13 KiB
Go
480 lines
13 KiB
Go
package azimuth
|
|
|
|
import (
|
|
"context"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/Khan/genqlient/graphql"
|
|
)
|
|
|
|
// reverseBytes reverses the byte order of the input slice
|
|
// This is needed because Urbit stores Ed25519 keys in reverse byte order
|
|
// compared to the standard Go crypto/ed25519 implementation
|
|
func reverseBytes(input []byte) []byte {
|
|
result := make([]byte, len(input))
|
|
for i, b := range input {
|
|
result[len(input)-1-i] = b
|
|
}
|
|
return result
|
|
}
|
|
|
|
// Default configuration constants
|
|
const (
|
|
// DefaultEndpoint is the default azimuth-watcher GraphQL endpoint
|
|
DefaultEndpoint = "https://azimuth.dev.vdb.to/graphql"
|
|
|
|
// AzimuthContract is the Azimuth contract address on Ethereum mainnet
|
|
AzimuthContract = "0x223c067F8CF28ae173EE5CafEa60cA44C335fecB"
|
|
|
|
// DefaultCacheTTL is the default cache duration for ship keys
|
|
DefaultCacheTTL = 1 * time.Hour
|
|
|
|
// DefaultTimeout is the default HTTP client timeout
|
|
DefaultTimeout = 30 * time.Second
|
|
)
|
|
|
|
// Client provides access to the Azimuth watcher GraphQL API
|
|
type Client struct {
|
|
gqlClient graphql.Client
|
|
cache *KeyCache
|
|
contractAddress string
|
|
}
|
|
|
|
// ShipKeys represents the cryptographic keys for an Urbit ship
|
|
type ShipKeys struct {
|
|
EncryptionKey []byte
|
|
AuthenticationKey []byte
|
|
CryptoSuiteVersion uint64
|
|
KeyRevisionNumber uint64
|
|
CachedAt time.Time
|
|
}
|
|
|
|
// KeyCache caches ship keys with TTL
|
|
type KeyCache struct {
|
|
mu sync.RWMutex
|
|
keys map[uint32]*ShipKeys
|
|
ttl time.Duration
|
|
}
|
|
|
|
// CachedKey represents a cached public key (for backwards compatibility)
|
|
type CachedKey struct {
|
|
PublicKey []byte
|
|
ExpiresAt time.Time
|
|
}
|
|
|
|
// PointOwnership represents point ownership information from Azimuth
|
|
type PointOwnership struct {
|
|
Owner string `json:"owner"`
|
|
IsActive bool `json:"is_active"`
|
|
HasSponsor bool `json:"has_sponsor"`
|
|
Sponsor uint32 `json:"sponsor,omitempty"`
|
|
}
|
|
|
|
// NewClient creates a new Azimuth client with default configuration
|
|
func NewClient(endpoint string) *Client {
|
|
return NewClientWithOptions(ClientOptions{
|
|
Endpoint: endpoint,
|
|
CacheTTL: DefaultCacheTTL,
|
|
Timeout: DefaultTimeout,
|
|
})
|
|
}
|
|
|
|
// ClientOptions configures the Azimuth client
|
|
type ClientOptions struct {
|
|
Endpoint string
|
|
CacheTTL time.Duration
|
|
ContractAddress string
|
|
Timeout time.Duration
|
|
}
|
|
|
|
// NewClientWithOptions creates a new Azimuth client with custom options
|
|
func NewClientWithOptions(opts ClientOptions) *Client {
|
|
if opts.Endpoint == "" {
|
|
opts.Endpoint = DefaultEndpoint
|
|
}
|
|
if opts.CacheTTL == 0 {
|
|
opts.CacheTTL = DefaultCacheTTL
|
|
}
|
|
if opts.ContractAddress == "" {
|
|
opts.ContractAddress = AzimuthContract
|
|
}
|
|
if opts.Timeout == 0 {
|
|
opts.Timeout = DefaultTimeout
|
|
}
|
|
|
|
httpClient := graphql.NewClient(opts.Endpoint, nil)
|
|
|
|
return &Client{
|
|
gqlClient: httpClient,
|
|
cache: NewKeyCache(opts.CacheTTL),
|
|
contractAddress: opts.ContractAddress,
|
|
}
|
|
}
|
|
|
|
// NewKeyCache creates a new key cache with the specified TTL
|
|
func NewKeyCache(ttl time.Duration) *KeyCache {
|
|
return &KeyCache{
|
|
keys: make(map[uint32]*ShipKeys),
|
|
ttl: ttl,
|
|
}
|
|
}
|
|
|
|
// Get retrieves ship keys from cache if valid
|
|
func (c *KeyCache) Get(point uint32) (*ShipKeys, bool) {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
|
|
cached, exists := c.keys[point]
|
|
if !exists {
|
|
return nil, false
|
|
}
|
|
|
|
if time.Since(cached.CachedAt) > c.ttl {
|
|
return nil, false
|
|
}
|
|
|
|
return cached, true
|
|
}
|
|
|
|
// Set adds ship keys to the cache
|
|
func (c *KeyCache) Set(point uint32, keys *ShipKeys) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
keys.CachedAt = time.Now()
|
|
c.keys[point] = keys
|
|
}
|
|
|
|
// GetShipType returns the type of ship based on its point number
|
|
func GetShipType(point uint32) string {
|
|
if point < 256 {
|
|
return "galaxy"
|
|
} else if point < 65536 {
|
|
return "star"
|
|
} else {
|
|
return "planet"
|
|
}
|
|
}
|
|
|
|
// ParseShipName converts an Urbit ship name to its point number
|
|
// Currently supports numeric IDs, TODO: implement proper @p parsing
|
|
func ParseShipName(shipName string) (uint32, error) {
|
|
// Remove ~ prefix if present
|
|
if len(shipName) > 0 && shipName[0] == '~' {
|
|
shipName = shipName[1:]
|
|
}
|
|
|
|
// For numeric IDs
|
|
if point, err := strconv.ParseUint(shipName, 10, 32); err == nil {
|
|
return uint32(point), nil
|
|
}
|
|
|
|
// TODO: Implement proper @p to point conversion
|
|
return 0, fmt.Errorf("ship name parsing not yet implemented for: %s", shipName)
|
|
}
|
|
|
|
// GetAuthenticationKey retrieves the authentication key for a ship
|
|
func (c *Client) GetAuthenticationKey(ctx context.Context, point uint32) ([]byte, error) {
|
|
// Check cache first
|
|
if keys, found := c.cache.Get(point); found {
|
|
return keys.AuthenticationKey, nil
|
|
}
|
|
|
|
// Query for all keys
|
|
keys, err := c.getShipKeys(ctx, point)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Cache the keys
|
|
c.cache.Set(point, keys)
|
|
|
|
return keys.AuthenticationKey, nil
|
|
}
|
|
|
|
// GetEncryptionKey retrieves the encryption key for a ship
|
|
func (c *Client) GetEncryptionKey(ctx context.Context, point uint32) ([]byte, error) {
|
|
// Check cache first
|
|
if keys, found := c.cache.Get(point); found {
|
|
return keys.EncryptionKey, nil
|
|
}
|
|
|
|
// Query for all keys
|
|
keys, err := c.getShipKeys(ctx, point)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Cache the keys
|
|
c.cache.Set(point, keys)
|
|
|
|
return keys.EncryptionKey, nil
|
|
}
|
|
|
|
// GetShipKeys retrieves all cryptographic keys for a ship
|
|
func (c *Client) GetShipKeys(ctx context.Context, point uint32) (*ShipKeys, error) {
|
|
// Check cache first
|
|
if keys, found := c.cache.Get(point); found {
|
|
return keys, nil
|
|
}
|
|
|
|
// Query for all keys
|
|
keys, err := c.getShipKeys(ctx, point)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Cache the keys
|
|
c.cache.Set(point, keys)
|
|
|
|
return keys, nil
|
|
}
|
|
|
|
// IsShipActive checks if a ship is active on the Azimuth network
|
|
func (c *Client) IsShipActive(ctx context.Context, point uint32) (bool, error) {
|
|
// Get current block hash
|
|
blockHash, err := c.getLatestBlockHash(ctx)
|
|
if err != nil {
|
|
return false, fmt.Errorf("failed to get latest block hash: %w", err)
|
|
}
|
|
|
|
// Convert point to BigInt
|
|
pointBigInt := NewBigIntFromUint32(point)
|
|
|
|
// Use generated GraphQL client
|
|
resp, err := IsActive(ctx, c.gqlClient, blockHash, c.contractAddress, pointBigInt)
|
|
if err != nil {
|
|
return false, fmt.Errorf("failed to query ship activity: %w", err)
|
|
}
|
|
|
|
return resp.AzimuthIsActive.Value, nil
|
|
}
|
|
|
|
// GetSponsor retrieves the sponsor of a point
|
|
func (c *Client) HasSponsor(ctx context.Context, point uint32) (bool, error) {
|
|
// Convert point to BigInt
|
|
pointBigInt := NewBigIntFromUint32(point)
|
|
|
|
// Use generated GraphQL client
|
|
resp, err := HasSponsor(ctx, c.gqlClient, "latest", c.contractAddress, pointBigInt)
|
|
if err != nil {
|
|
return false, fmt.Errorf("failed to query sponsor: %w", err)
|
|
}
|
|
|
|
// Parse sponsor point number from BigInt
|
|
hasSponsor := resp.AzimuthHasSponsor.Value
|
|
return hasSponsor, nil
|
|
}
|
|
|
|
// GetSponsor retrieves the sponsor of a point
|
|
func (c *Client) GetSponsor(ctx context.Context, point uint32) (uint32, error) {
|
|
// Get current block hash
|
|
blockHash, err := c.getLatestBlockHash(ctx)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to get latest block hash: %w", err)
|
|
}
|
|
|
|
// Convert point to BigInt
|
|
pointBigInt := NewBigIntFromUint32(point)
|
|
|
|
// Use generated GraphQL client
|
|
resp, err := GetSponsor(ctx, c.gqlClient, pointBigInt, blockHash, c.contractAddress)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to query sponsor: %w", err)
|
|
}
|
|
|
|
// Parse sponsor point number from BigInt
|
|
sponsorPoint, err := resp.AzimuthGetSponsor.Value.ToUint32()
|
|
if err != nil {
|
|
return 0, fmt.Errorf("invalid sponsor point format: %w", err)
|
|
}
|
|
|
|
return sponsorPoint, nil
|
|
}
|
|
|
|
// GetOwner retrieves the owner address of a point
|
|
func (c *Client) GetOwner(ctx context.Context, point uint32) (string, error) {
|
|
// Get current block hash
|
|
blockHash, err := c.getLatestBlockHash(ctx)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to get latest block hash: %w", err)
|
|
}
|
|
|
|
// Convert point to BigInt
|
|
pointBigInt := NewBigIntFromUint32(point)
|
|
|
|
// Use generated GraphQL client
|
|
resp, err := GetOwner(ctx, c.gqlClient, blockHash, c.contractAddress, pointBigInt)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to query owner: %w", err)
|
|
}
|
|
|
|
return resp.AzimuthGetOwner.Value, nil
|
|
}
|
|
|
|
// GetPointOwnership retrieves comprehensive ownership information for a point
|
|
func (c *Client) GetPointOwnership(ctx context.Context, point uint32) (*PointOwnership, error) {
|
|
// Convert point to BigInt
|
|
pointBigInt := NewBigIntFromUint32(point)
|
|
|
|
// Query for owner using "latest" block
|
|
ownerResp, err := GetOwner(ctx, c.gqlClient, "latest", c.contractAddress, pointBigInt)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to query owner: %w", err)
|
|
}
|
|
|
|
// Query for active status
|
|
activeResp, err := IsActive(ctx, c.gqlClient, "latest", c.contractAddress, pointBigInt)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to query active status: %w", err)
|
|
}
|
|
|
|
// Query for sponsor status using HasSponsor
|
|
hasSponsorResp, err := HasSponsor(ctx, c.gqlClient, "latest", c.contractAddress, pointBigInt)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to query sponsor status: %w", err)
|
|
}
|
|
|
|
// Query for sponsor point number
|
|
sponsorPoint := uint32(0)
|
|
if hasSponsorResp.AzimuthHasSponsor.Value {
|
|
sponsorResp, err := GetSponsor(ctx, c.gqlClient, pointBigInt, "latest", c.contractAddress)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to query sponsor: %w", err)
|
|
}
|
|
if parsed, err := sponsorResp.AzimuthGetSponsor.Value.ToUint32(); err == nil {
|
|
sponsorPoint = parsed
|
|
}
|
|
}
|
|
|
|
return &PointOwnership{
|
|
Owner: ownerResp.AzimuthGetOwner.Value,
|
|
IsActive: activeResp.AzimuthIsActive.Value,
|
|
HasSponsor: hasSponsorResp.AzimuthHasSponsor.Value,
|
|
Sponsor: sponsorPoint,
|
|
}, nil
|
|
}
|
|
|
|
// getShipKeys retrieves all keys for a ship from the API
|
|
func (c *Client) getShipKeys(ctx context.Context, point uint32) (*ShipKeys, error) {
|
|
// Get current block hash
|
|
blockHash, err := c.getLatestBlockHash(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get latest block hash: %w", err)
|
|
}
|
|
|
|
// Convert point to BigInt
|
|
pointBigInt := NewBigIntFromUint32(point)
|
|
|
|
// Use generated GraphQL client
|
|
resp, err := GetKeys(ctx, c.gqlClient, blockHash, c.contractAddress, pointBigInt)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to query keys: %w", err)
|
|
}
|
|
|
|
// Check if keys exist for this point
|
|
if resp.AzimuthGetKeys.Value.Value1 == "" {
|
|
return nil, fmt.Errorf("no keys found for point %d", point)
|
|
}
|
|
|
|
keys := &ShipKeys{}
|
|
|
|
// Parse encryption key (32 bytes hex, remove 0x prefix if present)
|
|
encKeyHex := resp.AzimuthGetKeys.Value.Value0
|
|
if len(encKeyHex) > 2 && encKeyHex[:2] == "0x" {
|
|
encKeyHex = encKeyHex[2:]
|
|
}
|
|
if encKeyHex != "" {
|
|
encKey, err := hex.DecodeString(encKeyHex)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to decode encryption key: %w", err)
|
|
}
|
|
// Reverse the authentication key bytes for Urbit Ed25519 compatibility
|
|
keys.EncryptionKey = reverseBytes(encKey)
|
|
}
|
|
|
|
// Parse authentication key (32 bytes hex, remove 0x prefix if present)
|
|
authKeyHex := resp.AzimuthGetKeys.Value.Value1
|
|
if len(authKeyHex) > 2 && authKeyHex[:2] == "0x" {
|
|
authKeyHex = authKeyHex[2:]
|
|
}
|
|
authKey, err := hex.DecodeString(authKeyHex)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to decode authentication key: %w", err)
|
|
}
|
|
if len(authKey) != 32 {
|
|
return nil, fmt.Errorf("invalid authentication key length: expected 32 bytes, got %d", len(authKey))
|
|
}
|
|
// Reverse the authentication key bytes for Urbit Ed25519 compatibility
|
|
keys.AuthenticationKey = reverseBytes(authKey)
|
|
|
|
// Parse crypto suite version
|
|
suite, err := resp.AzimuthGetKeys.Value.Value2.ToUint32()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse crypto suite version: %w", err)
|
|
}
|
|
keys.CryptoSuiteVersion = uint64(suite)
|
|
|
|
// Parse key revision number
|
|
revision, err := resp.AzimuthGetKeys.Value.Value3.ToUint32()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse key revision: %w", err)
|
|
}
|
|
keys.KeyRevisionNumber = uint64(revision)
|
|
|
|
return keys, nil
|
|
}
|
|
|
|
// getLatestBlockHash gets the latest processed block hash from azimuth-watcher
|
|
func (c *Client) getLatestBlockHash(ctx context.Context) (string, error) {
|
|
// Use generated GraphQL client
|
|
resp, err := GetSyncStatus(ctx, c.gqlClient)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to query sync status: %w", err)
|
|
}
|
|
|
|
return resp.AzimuthGetSyncStatus.LatestProcessedBlockHash, nil
|
|
}
|
|
|
|
// ValidateStarSponsorship validates that a star is sponsored by the expected galaxy
|
|
func (c *Client) ValidateStarSponsorship(ctx context.Context, starID uint32, expectedGalaxyID uint32) error {
|
|
hasSponsor, err := c.HasSponsor(ctx, starID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get sponsor: %w", err)
|
|
}
|
|
if !hasSponsor {
|
|
return fmt.Errorf("star %d is not sponsored by galaxy %d (no sponsor)",
|
|
starID, expectedGalaxyID)
|
|
}
|
|
sponsor, err := c.GetSponsor(ctx, starID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get sponsor: %w", err)
|
|
}
|
|
|
|
if sponsor != expectedGalaxyID {
|
|
return fmt.Errorf("star %d is not sponsored by galaxy %d (actual sponsor: %d)",
|
|
starID, expectedGalaxyID, sponsor)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ParseStarID parses a star ID from string to uint32 with validation
|
|
func ParseStarID(starIDStr string) (uint32, error) {
|
|
starPoint, err := strconv.ParseUint(starIDStr, 10, 32)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("invalid star ID format: %w", err)
|
|
}
|
|
|
|
// Validate that it's actually a star (256-65535)
|
|
if starPoint < 256 || starPoint > 65535 {
|
|
return 0, fmt.Errorf("ID %d is not a valid star (must be 256-65535)", starPoint)
|
|
}
|
|
|
|
return uint32(starPoint), nil
|
|
}
|