azimuth-client-go/client.go
2025-09-11 18:28:40 -07:00

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
}