mirror of
https://github.com/luxfi/cosigner.git
synced 2026-10-30 16:59:04 +00:00
Decomplect Fireblocks / Utila / Anchorage co-signing out of luxfi/bridge into one orthogonal, stdlib-only leaf. Wire-shape validation keeps secret material off the wire; env + KMS secret stores fetch the secret half by public id; the real Fireblocks REST + RS256-JWT family signs, the Utila and Anchorage families fail loud until their vendor clients land; the dispatcher runs families in parallel behind an AllApproved gate. Errors name the cosigner KIND and never echo the resolved URI, tenant id, or secret path.
162 lines
7.2 KiB
Go
162 lines
7.2 KiB
Go
package cosigner
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
// utila.go — Utila Connect-RPC cosigner family.
|
|
//
|
|
// STATUS: BLOCKED ON VENDOR SPEC. RunUtila returns a typed
|
|
// *NotImplementedError (Status == StatusFailed, errors.Is(Err,
|
|
// ErrNotImplemented) == true). It is NOT a silent stub — every Utila
|
|
// intent fails LOUDLY and names the precise missing artifact.
|
|
//
|
|
// What's missing: a Go @luxfi/utila Connect-RPC client (or the .proto
|
|
// source to generate one). The only Utila client in the tree is the
|
|
// TypeScript package `@luxfi/utila` v3.0.0 — generated `*_pb.ts`
|
|
// Connect-RPC stubs under bridge/pkg/utila/src/lib/gen/utila/api/...,
|
|
// with NO `.proto` source and NO Go bindings committed. Hand-rolling the
|
|
// Utila protobuf + Connect-RPC wire format in Go without the spec would
|
|
// be exactly the "stub dressed up as real" this module forbids, so we
|
|
// stop at the honest interface boundary.
|
|
//
|
|
// When the Go client / proto lands, fill in RunUtila per the documented
|
|
// flow below — no other type or interface change is required. The
|
|
// structure mirrors FireblocksRESTFamily so the future port is a
|
|
// contained change to ONE method.
|
|
//
|
|
// Reference TS implementation (the flow to replicate):
|
|
// app/server/src/domain/cosigners.ts runUtila — initiateTransaction
|
|
// (evm_personal_sign over opts.TxHash, scoped to vaults/<vault_id>),
|
|
// then poll getTransaction until a terminal TransactionState_Enum:
|
|
//
|
|
// approved (sig present): SIGNED=4, AWAITING_PUBLISH=5, PUBLISHED=6,
|
|
// MINED=7, CONFIRMED=13
|
|
// rejected: DECLINED=9, CANCELED=11, DROPPED=12, EXPIRED=15
|
|
// failed: FAILED=8, REPLACED=10
|
|
|
|
// utilaMissingArtifact is the precise blocker named in every Utila
|
|
// not-implemented Result. Kept as one constant so the message is
|
|
// identical everywhere and trivially greppable.
|
|
const utilaMissingArtifact = "@luxfi/utila Connect-RPC Go client / .proto source " +
|
|
"(only the TypeScript pkg/utila v3.0.0 generated _pb.ts client exists; no Go bindings or .proto committed)"
|
|
|
|
// UtilaConnectRPCFamily implements FamilyDispatcher with — once the real
|
|
// @luxfi/utila Go client lands — the real Connect-RPC transaction-
|
|
// approval flow on the Utila side. The other families delegate to their
|
|
// configured delegates (default StubFamilyDispatcher) so an operator who
|
|
// only wants real Utila doesn't accidentally force the others.
|
|
type UtilaConnectRPCFamily struct {
|
|
// FireblocksDelegate routes RunFireblocks calls when this family is
|
|
// wired standalone. Zero ⇒ StubFamilyDispatcher. Production wiring
|
|
// that wants multiple real families should compose via
|
|
// CompositeFamilyDispatcher instead of setting this field.
|
|
FireblocksDelegate FamilyDispatcher
|
|
|
|
// AnchorageDelegate routes RunAnchorage calls when this family is
|
|
// wired standalone. Zero ⇒ StubFamilyDispatcher.
|
|
AnchorageDelegate FamilyDispatcher
|
|
|
|
// Timeout caps the entire Utila approval flow (create + polls or
|
|
// webhook wait) once the real implementation lands. Currently unused
|
|
// because RunUtila is honestly blocked.
|
|
Timeout time.Duration
|
|
|
|
// (Other knobs — HTTPClient, PollInterval, Now, Nonce, etc. — land
|
|
// with the real client, mirroring FireblocksRESTFamily's pluggable
|
|
// surface so tests can inject deterministic time + transport.)
|
|
}
|
|
|
|
// RunUtila is the integration point for the real Utila Connect-RPC
|
|
// client. Today it fails LOUDLY with a typed *NotImplementedError naming
|
|
// the missing artifact + the call context (swap id, org, client, vault)
|
|
// for traceability. The "all listed must approve" policy then moves the
|
|
// swap to refund so the user's deposit is returned — never a silent drop.
|
|
func (u UtilaConnectRPCFamily) RunUtila(_ context.Context, intent *UtilaIntent, _ string, opts DispatchOptions) Result {
|
|
detail := fmt.Sprintf("swap=%s org=%s client=%s vault=%s",
|
|
opts.SwapID, intent.OrgID, intent.ClientID, vaultOrDefault(intent.VaultID, "(tenant default)"))
|
|
return notImplemented(Intent{Kind: KindUtila, Utila: intent}, utilaMissingArtifact, detail)
|
|
}
|
|
|
|
// RunFireblocks delegates to the configured FireblocksDelegate (default
|
|
// stub) so a standalone wiring still has defined behaviour for Fireblocks.
|
|
func (u UtilaConnectRPCFamily) RunFireblocks(ctx context.Context, intent *FireblocksIntent, secret string, opts DispatchOptions) Result {
|
|
delegate := u.FireblocksDelegate
|
|
if delegate == nil {
|
|
delegate = StubFamilyDispatcher{}
|
|
}
|
|
return delegate.RunFireblocks(ctx, intent, secret, opts)
|
|
}
|
|
|
|
// RunAnchorage delegates to the configured AnchorageDelegate (default stub).
|
|
func (u UtilaConnectRPCFamily) RunAnchorage(ctx context.Context, intent *AnchorageIntent, secret string, opts DispatchOptions) Result {
|
|
delegate := u.AnchorageDelegate
|
|
if delegate == nil {
|
|
delegate = StubFamilyDispatcher{}
|
|
}
|
|
return delegate.RunAnchorage(ctx, intent, secret, opts)
|
|
}
|
|
|
|
// CompositeFamilyDispatcher routes per-family calls to separately
|
|
// configured runners. This is THE composition mechanism — the one way to
|
|
// wire several real families together:
|
|
//
|
|
// families := cosigner.CompositeFamilyDispatcher{
|
|
// FireblocksFamily: cosigner.FireblocksRESTFamily{Timeout: 60 * time.Second},
|
|
// UtilaFamily: cosigner.UtilaConnectRPCFamily{Timeout: 60 * time.Second}, // blocked today
|
|
// AnchorageFamily: cosigner.AnchorageFamily{Timeout: 60 * time.Second}, // blocked today
|
|
// }
|
|
// dispatcher := cosigner.NewDefault(secretStore, families)
|
|
//
|
|
// Any field may be nil — a nil family falls back to StubFamilyDispatcher
|
|
// for that family (loud failure). The recommended production wiring sets
|
|
// only the families it has real implementations for; intents for a nil
|
|
// family fail with the stub reason.
|
|
type CompositeFamilyDispatcher struct {
|
|
// UtilaFamily is consulted on every RunUtila call. Zero ⇒ stub.
|
|
UtilaFamily FamilyDispatcher
|
|
// FireblocksFamily is consulted on every RunFireblocks call. Zero ⇒ stub.
|
|
FireblocksFamily FamilyDispatcher
|
|
// AnchorageFamily is consulted on every RunAnchorage call. Zero ⇒ stub.
|
|
AnchorageFamily FamilyDispatcher
|
|
}
|
|
|
|
// RunUtila delegates to UtilaFamily (or the stub).
|
|
func (c CompositeFamilyDispatcher) RunUtila(ctx context.Context, intent *UtilaIntent, secret string, opts DispatchOptions) Result {
|
|
delegate := c.UtilaFamily
|
|
if delegate == nil {
|
|
delegate = StubFamilyDispatcher{}
|
|
}
|
|
return delegate.RunUtila(ctx, intent, secret, opts)
|
|
}
|
|
|
|
// RunFireblocks delegates to FireblocksFamily (or the stub).
|
|
func (c CompositeFamilyDispatcher) RunFireblocks(ctx context.Context, intent *FireblocksIntent, secret string, opts DispatchOptions) Result {
|
|
delegate := c.FireblocksFamily
|
|
if delegate == nil {
|
|
delegate = StubFamilyDispatcher{}
|
|
}
|
|
return delegate.RunFireblocks(ctx, intent, secret, opts)
|
|
}
|
|
|
|
// RunAnchorage delegates to AnchorageFamily (or the stub).
|
|
func (c CompositeFamilyDispatcher) RunAnchorage(ctx context.Context, intent *AnchorageIntent, secret string, opts DispatchOptions) Result {
|
|
delegate := c.AnchorageFamily
|
|
if delegate == nil {
|
|
delegate = StubFamilyDispatcher{}
|
|
}
|
|
return delegate.RunAnchorage(ctx, intent, secret, opts)
|
|
}
|
|
|
|
// vaultOrDefault returns vault when non-empty, otherwise the supplied
|
|
// default placeholder. Tiny helper to keep not-implemented messages
|
|
// readable when the intent omits VaultID.
|
|
func vaultOrDefault(vault, def string) string {
|
|
if vault == "" {
|
|
return def
|
|
}
|
|
return vault
|
|
}
|