azimuth-client-go/client.go
Theodore Blackman 4d7ab16549 Initial release of azimuth-client-go library
- Consolidated azimuth client implementations from zenithd and janus
- Type-safe GraphQL client using genqlient code generation
- Comprehensive caching with configurable TTL (1 hour default)
- Full API coverage: authentication keys, ship activity, sponsorship, ownership
- Enhanced functionality combining best features from both original implementations
- Extensive test suite with 16 unit tests covering all functionality
- Complete documentation with examples and usage patterns
- Support for galaxy, star, and planet ship type classification
- BigInt utility for proper GraphQL number handling
- MIT licensed for open source usage

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-30 14:01:03 -04:00

395 lines
10 KiB
Go

package azimuth
import (
"context"
"encoding/hex"
"fmt"
"strconv"
"sync"
"time"
"github.com/Khan/genqlient/graphql"
)
// 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
}
// 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) 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 string
if resp.AzimuthGetSponsor.Value == "" {
return 0, fmt.Errorf("point %d has no sponsor", point)
}
sponsorPoint, err := strconv.ParseUint(resp.AzimuthGetSponsor.Value, 10, 32)
if err != nil {
return 0, fmt.Errorf("invalid sponsor point format: %w", err)
}
return uint32(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
}
// 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)
}
keys.EncryptionKey = 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))
}
keys.AuthenticationKey = 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 ValidateStarSponsorship(ctx context.Context, starID uint32, expectedGalaxyID uint32, client *Client) error {
sponsor, err := client.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
}