// Package bridge provides cross-chain asset transfer functionality. package bridge import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "sync/atomic" "time" ) // Client is a Synor Bridge client for cross-chain transfers. type Client struct { config Config httpClient *http.Client closed atomic.Bool } // NewClient creates a new bridge client with the given configuration. func NewClient(config Config) *Client { if config.Endpoint == "" { config.Endpoint = DefaultEndpoint } if config.Timeout == 0 { config.Timeout = 60 * time.Second } if config.Retries == 0 { config.Retries = 3 } return &Client{ config: config, httpClient: &http.Client{ Timeout: config.Timeout, }, } } // ==================== Chain Operations ==================== // GetSupportedChains returns all supported chains. func (c *Client) GetSupportedChains(ctx context.Context) ([]Chain, error) { var resp struct { Chains []Chain `json:"chains"` } if err := c.request(ctx, "GET", "/chains", nil, &resp); err != nil { return nil, err } return resp.Chains, nil } // GetChain returns chain information by ID. func (c *Client) GetChain(ctx context.Context, chainID ChainID) (*Chain, error) { var chain Chain if err := c.request(ctx, "GET", fmt.Sprintf("/chains/%s", chainID), nil, &chain); err != nil { return nil, err } return &chain, nil } // IsChainSupported checks if a chain is supported. func (c *Client) IsChainSupported(ctx context.Context, chainID ChainID) bool { chain, err := c.GetChain(ctx, chainID) if err != nil { return false } return chain.Supported } // ==================== Asset Operations ==================== // GetSupportedAssets returns supported assets for a chain. func (c *Client) GetSupportedAssets(ctx context.Context, chainID ChainID) ([]Asset, error) { var resp struct { Assets []Asset `json:"assets"` } if err := c.request(ctx, "GET", fmt.Sprintf("/chains/%s/assets", chainID), nil, &resp); err != nil { return nil, err } return resp.Assets, nil } // GetAsset returns asset information by ID. func (c *Client) GetAsset(ctx context.Context, assetID string) (*Asset, error) { var asset Asset if err := c.request(ctx, "GET", fmt.Sprintf("/assets/%s", assetID), nil, &asset); err != nil { return nil, err } return &asset, nil } // GetWrappedAsset returns the wrapped asset mapping. func (c *Client) GetWrappedAsset(ctx context.Context, originalAssetID string, targetChain ChainID) (*WrappedAsset, error) { var wrapped WrappedAsset path := fmt.Sprintf("/assets/%s/wrapped/%s", originalAssetID, targetChain) if err := c.request(ctx, "GET", path, nil, &wrapped); err != nil { return nil, err } return &wrapped, nil } // GetWrappedAssets returns all wrapped assets for a chain. func (c *Client) GetWrappedAssets(ctx context.Context, chainID ChainID) ([]WrappedAsset, error) { var resp struct { Assets []WrappedAsset `json:"assets"` } if err := c.request(ctx, "GET", fmt.Sprintf("/chains/%s/wrapped", chainID), nil, &resp); err != nil { return nil, err } return resp.Assets, nil } // ==================== Fee & Rate Operations ==================== // EstimateFee estimates the bridge fee for a transfer. func (c *Client) EstimateFee(ctx context.Context, asset, amount string, sourceChain, targetChain ChainID) (*FeeEstimate, error) { body := map[string]interface{}{ "asset": asset, "amount": amount, "sourceChain": sourceChain, "targetChain": targetChain, } var estimate FeeEstimate if err := c.request(ctx, "POST", "/fees/estimate", body, &estimate); err != nil { return nil, err } return &estimate, nil } // GetExchangeRate returns the exchange rate between two assets. func (c *Client) GetExchangeRate(ctx context.Context, fromAsset, toAsset string) (*ExchangeRate, error) { var rate ExchangeRate path := fmt.Sprintf("/rates/%s/%s", url.PathEscape(fromAsset), url.PathEscape(toAsset)) if err := c.request(ctx, "GET", path, nil, &rate); err != nil { return nil, err } return &rate, nil } // ==================== Lock-Mint Flow ==================== // Lock locks assets on the source chain for cross-chain transfer. // This is step 1 of the lock-mint flow. func (c *Client) Lock(ctx context.Context, asset, amount string, targetChain ChainID, opts *LockOptions) (*LockReceipt, error) { body := map[string]interface{}{ "asset": asset, "amount": amount, "targetChain": targetChain, } if opts != nil { if opts.Recipient != "" { body["recipient"] = opts.Recipient } if opts.Deadline != 0 { body["deadline"] = opts.Deadline } if opts.Slippage != 0 { body["slippage"] = opts.Slippage } } var receipt LockReceipt if err := c.request(ctx, "POST", "/transfers/lock", body, &receipt); err != nil { return nil, err } return &receipt, nil } // GetLockProof gets the lock proof for minting. // This is step 2 of the lock-mint flow. func (c *Client) GetLockProof(ctx context.Context, lockReceiptID string) (*LockProof, error) { var proof LockProof path := fmt.Sprintf("/transfers/lock/%s/proof", lockReceiptID) if err := c.request(ctx, "GET", path, nil, &proof); err != nil { return nil, err } return &proof, nil } // WaitForLockProof waits for the lock proof to be ready. func (c *Client) WaitForLockProof(ctx context.Context, lockReceiptID string, pollInterval, maxWait time.Duration) (*LockProof, error) { if pollInterval == 0 { pollInterval = 5 * time.Second } if maxWait == 0 { maxWait = 10 * time.Minute } deadline := time.Now().Add(maxWait) for time.Now().Before(deadline) { proof, err := c.GetLockProof(ctx, lockReceiptID) if err == nil { return proof, nil } if bridgeErr, ok := err.(*Error); ok && bridgeErr.Code == "CONFIRMATIONS_PENDING" { select { case <-ctx.Done(): return nil, ctx.Err() case <-time.After(pollInterval): continue } } return nil, err } return nil, &Error{Message: "Timeout waiting for lock proof", Code: "CONFIRMATIONS_PENDING"} } // Mint mints wrapped tokens on the target chain. // This is step 3 of the lock-mint flow. func (c *Client) Mint(ctx context.Context, proof *LockProof, targetAddress string, opts *MintOptions) (*SignedTransaction, error) { body := map[string]interface{}{ "proof": proof, "targetAddress": targetAddress, } if opts != nil { if opts.GasLimit != "" { body["gasLimit"] = opts.GasLimit } if opts.MaxFeePerGas != "" { body["maxFeePerGas"] = opts.MaxFeePerGas } if opts.MaxPriorityFeePerGas != "" { body["maxPriorityFeePerGas"] = opts.MaxPriorityFeePerGas } } var tx SignedTransaction if err := c.request(ctx, "POST", "/transfers/mint", body, &tx); err != nil { return nil, err } return &tx, nil } // ==================== Burn-Unlock Flow ==================== // Burn burns wrapped tokens on the current chain. // This is step 1 of the burn-unlock flow. func (c *Client) Burn(ctx context.Context, wrappedAsset, amount string, opts *BurnOptions) (*BurnReceipt, error) { body := map[string]interface{}{ "wrappedAsset": wrappedAsset, "amount": amount, } if opts != nil { if opts.Recipient != "" { body["recipient"] = opts.Recipient } if opts.Deadline != 0 { body["deadline"] = opts.Deadline } } var receipt BurnReceipt if err := c.request(ctx, "POST", "/transfers/burn", body, &receipt); err != nil { return nil, err } return &receipt, nil } // GetBurnProof gets the burn proof for unlocking. // This is step 2 of the burn-unlock flow. func (c *Client) GetBurnProof(ctx context.Context, burnReceiptID string) (*BurnProof, error) { var proof BurnProof path := fmt.Sprintf("/transfers/burn/%s/proof", burnReceiptID) if err := c.request(ctx, "GET", path, nil, &proof); err != nil { return nil, err } return &proof, nil } // WaitForBurnProof waits for the burn proof to be ready. func (c *Client) WaitForBurnProof(ctx context.Context, burnReceiptID string, pollInterval, maxWait time.Duration) (*BurnProof, error) { if pollInterval == 0 { pollInterval = 5 * time.Second } if maxWait == 0 { maxWait = 10 * time.Minute } deadline := time.Now().Add(maxWait) for time.Now().Before(deadline) { proof, err := c.GetBurnProof(ctx, burnReceiptID) if err == nil { return proof, nil } if bridgeErr, ok := err.(*Error); ok && bridgeErr.Code == "CONFIRMATIONS_PENDING" { select { case <-ctx.Done(): return nil, ctx.Err() case <-time.After(pollInterval): continue } } return nil, err } return nil, &Error{Message: "Timeout waiting for burn proof", Code: "CONFIRMATIONS_PENDING"} } // Unlock unlocks original tokens on the source chain. // This is step 3 of the burn-unlock flow. func (c *Client) Unlock(ctx context.Context, proof *BurnProof, opts *UnlockOptions) (*SignedTransaction, error) { body := map[string]interface{}{ "proof": proof, } if opts != nil { if opts.GasLimit != "" { body["gasLimit"] = opts.GasLimit } if opts.GasPrice != "" { body["gasPrice"] = opts.GasPrice } } var tx SignedTransaction if err := c.request(ctx, "POST", "/transfers/unlock", body, &tx); err != nil { return nil, err } return &tx, nil } // ==================== Transfer Management ==================== // GetTransfer returns a transfer by ID. func (c *Client) GetTransfer(ctx context.Context, transferID string) (*Transfer, error) { var transfer Transfer if err := c.request(ctx, "GET", fmt.Sprintf("/transfers/%s", transferID), nil, &transfer); err != nil { return nil, err } return &transfer, nil } // GetTransferStatus returns the status of a transfer. func (c *Client) GetTransferStatus(ctx context.Context, transferID string) (TransferStatus, error) { transfer, err := c.GetTransfer(ctx, transferID) if err != nil { return "", err } return transfer.Status, nil } // ListTransfers returns transfers matching the filter. func (c *Client) ListTransfers(ctx context.Context, filter *TransferFilter) ([]Transfer, error) { params := url.Values{} if filter != nil { if filter.Status != "" { params.Set("status", string(filter.Status)) } if filter.SourceChain != "" { params.Set("sourceChain", string(filter.SourceChain)) } if filter.TargetChain != "" { params.Set("targetChain", string(filter.TargetChain)) } if filter.Asset != "" { params.Set("asset", filter.Asset) } if filter.Sender != "" { params.Set("sender", filter.Sender) } if filter.Recipient != "" { params.Set("recipient", filter.Recipient) } if filter.FromDate != nil { params.Set("fromDate", strconv.FormatInt(filter.FromDate.Unix(), 10)) } if filter.ToDate != nil { params.Set("toDate", strconv.FormatInt(filter.ToDate.Unix(), 10)) } if filter.Limit > 0 { params.Set("limit", strconv.Itoa(filter.Limit)) } if filter.Offset > 0 { params.Set("offset", strconv.Itoa(filter.Offset)) } } path := "/transfers" if len(params) > 0 { path = "/transfers?" + params.Encode() } var resp struct { Transfers []Transfer `json:"transfers"` } if err := c.request(ctx, "GET", path, nil, &resp); err != nil { return nil, err } return resp.Transfers, nil } // WaitForTransfer waits for a transfer to complete. func (c *Client) WaitForTransfer(ctx context.Context, transferID string, pollInterval, maxWait time.Duration) (*Transfer, error) { if pollInterval == 0 { pollInterval = 10 * time.Second } if maxWait == 0 { maxWait = 30 * time.Minute } finalStatuses := map[TransferStatus]bool{ TransferStatusCompleted: true, TransferStatusFailed: true, TransferStatusRefunded: true, } deadline := time.Now().Add(maxWait) for time.Now().Before(deadline) { transfer, err := c.GetTransfer(ctx, transferID) if err != nil { return nil, err } if finalStatuses[transfer.Status] { return transfer, nil } select { case <-ctx.Done(): return nil, ctx.Err() case <-time.After(pollInterval): continue } } return nil, &Error{Message: "Timeout waiting for transfer completion"} } // ==================== Convenience Methods ==================== // BridgeTo executes a complete lock-mint transfer. func (c *Client) BridgeTo(ctx context.Context, asset, amount string, targetChain ChainID, targetAddress string, lockOpts *LockOptions, mintOpts *MintOptions) (*Transfer, error) { // Lock on source chain lockReceipt, err := c.Lock(ctx, asset, amount, targetChain, lockOpts) if err != nil { return nil, err } if c.config.Debug { fmt.Printf("Locked: %s, waiting for confirmations...\n", lockReceipt.ID) } // Wait for proof proof, err := c.WaitForLockProof(ctx, lockReceipt.ID, 0, 0) if err != nil { return nil, err } if c.config.Debug { fmt.Printf("Proof ready, minting on %s...\n", targetChain) } // Mint on target chain _, err = c.Mint(ctx, proof, targetAddress, mintOpts) if err != nil { return nil, err } // Return final transfer status return c.WaitForTransfer(ctx, lockReceipt.ID, 0, 0) } // BridgeBack executes a complete burn-unlock transfer. func (c *Client) BridgeBack(ctx context.Context, wrappedAsset, amount string, burnOpts *BurnOptions, unlockOpts *UnlockOptions) (*Transfer, error) { // Burn wrapped tokens burnReceipt, err := c.Burn(ctx, wrappedAsset, amount, burnOpts) if err != nil { return nil, err } if c.config.Debug { fmt.Printf("Burned: %s, waiting for confirmations...\n", burnReceipt.ID) } // Wait for proof proof, err := c.WaitForBurnProof(ctx, burnReceipt.ID, 0, 0) if err != nil { return nil, err } if c.config.Debug { fmt.Printf("Proof ready, unlocking on %s...\n", burnReceipt.TargetChain) } // Unlock on original chain _, err = c.Unlock(ctx, proof, unlockOpts) if err != nil { return nil, err } // Return final transfer status return c.WaitForTransfer(ctx, burnReceipt.ID, 0, 0) } // ==================== Lifecycle ==================== // Close closes the client. func (c *Client) Close() { c.closed.Store(true) } // IsClosed returns whether the client is closed. func (c *Client) IsClosed() bool { return c.closed.Load() } // HealthCheck performs a health check. func (c *Client) HealthCheck(ctx context.Context) bool { var resp struct { Status string `json:"status"` } if err := c.request(ctx, "GET", "/health", nil, &resp); err != nil { return false } return resp.Status == "healthy" } // ==================== Private Methods ==================== func (c *Client) request(ctx context.Context, method, path string, body interface{}, result interface{}) error { if c.IsClosed() { return &Error{Message: "Client has been closed"} } var lastErr error for attempt := 0; attempt < c.config.Retries; attempt++ { err := c.doRequest(ctx, method, path, body, result) if err == nil { return nil } if c.config.Debug { fmt.Printf("Attempt %d failed: %v\n", attempt+1, err) } lastErr = err if attempt < c.config.Retries-1 { time.Sleep(time.Duration(1<= 400 { var errorResp struct { Message string `json:"message"` Error string `json:"error"` Code string `json:"code"` } json.Unmarshal(respBody, &errorResp) message := errorResp.Message if message == "" { message = errorResp.Error } if message == "" { message = fmt.Sprintf("HTTP %d", resp.StatusCode) } return &Error{ Message: message, Code: errorResp.Code, StatusCode: resp.StatusCode, } } if result != nil { if err := json.Unmarshal(respBody, result); err != nil { return err } } return nil } // Error represents a bridge error. type Error struct { Message string Code string StatusCode int } func (e *Error) Error() string { return e.Message }