withdrawals

package
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 16, 2026 License: Apache-2.0 Imports: 22 Imported by: 0

Documentation

Overview

Package withdrawals implements the data-model §5 withdrawal state machine over storage.Store: the FR-032 pre-sign checklist, the externally supplied compliance gate, idempotency-key single execution (FR-033), simulation and ceiling-rounded fee calculation (FR-040), and the FR-035 broadcaster (broadcast.go) with its eight status distinctions.

Every status write goes through the repository layer, which enforces the legal transitions; concurrent drivers lose optimistically with ErrStatusConflict instead of double-executing. Sequences come exclusively from sequences.Manager (FR-034); signing crosses only the signer.TransactionSigner boundary; signed bytes are persisted at SIGNED so recovery can rebroadcast identical bytes and never re-sign.

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrSignatureInvalid reports a signer response whose signature does not
	// verify over SHA-256(signDocBytes) for the returned public key.
	ErrSignatureInvalid = errors.New("withdrawals: signature invalid over sign doc")

	// ErrWrongSigningKey reports a signer response whose public key does not
	// derive the intended sender address.
	ErrWrongSigningKey = errors.New("withdrawals: public key does not derive the expected sender")
)
View Source
var (
	// ErrInvalidRequest reports a request rejected before any record exists.
	ErrInvalidRequest = errors.New("withdrawals: invalid request")

	// ErrAwaitingCompliance reports a withdrawal blocked on the externally
	// supplied compliance gate (FR-031); the kit never decides it.
	ErrAwaitingCompliance = errors.New("withdrawals: awaiting external compliance approval")

	// ErrBelowMinimum reports an amount under the configured exchange minimum.
	ErrBelowMinimum = errors.New("withdrawals: amount below configured minimum")

	// ErrInsufficientSpendable reports a hot wallet that cannot cover
	// amount + max fee.
	ErrInsufficientSpendable = errors.New("withdrawals: insufficient spendable balance")

	// ErrSimulationUnavailable reports the simulate-unavailable queue policy:
	// the withdrawal stays queued at TRANSACTION_BUILT until simulation
	// returns or the operator opts into the static policy.
	ErrSimulationUnavailable = errors.New("withdrawals: simulation unavailable; withdrawal held (simulate_unavailable: queue)")

	// ErrFeeExceedsMax reports a computed fee above max_fee_usovr; the
	// withdrawal is quarantined, never broadcast with an unbounded fee.
	ErrFeeExceedsMax = errors.New("withdrawals: computed fee exceeds max_fee_usovr")

	// ErrPaused reports an operational-controls pause for the needed flow.
	ErrPaused = errors.New("withdrawals: flow paused by operational controls")

	// ErrQuarantined reports a withdrawal moved to REVIEW_REQUIRED by the
	// current call; details are on the review-queue item.
	ErrQuarantined = errors.New("withdrawals: quarantined as REVIEW_REQUIRED")

	// ErrSignerUnavailable reports a signer outage: the withdrawal stays
	// queued (graceful degradation — nothing unsigned is ever broadcast).
	ErrSignerUnavailable = errors.New("withdrawals: signer unavailable; withdrawal remains queued")
)

Functions

func VerifySignedResponse

func VerifySignedResponse(signDocBytes []byte, resp signer.SigningResponse, expectedSender string) error

VerifySignedResponse is the mandatory adapter-side check before anything is persisted as SIGNED (contracts/signer-interface.md §Adapter-side verification): (a) resp.Signature is a valid 64-byte R||S signature over SHA-256(signDocBytes) for resp.PubKeyCompressed, and (b) that public key derives expectedSender. Either failure means the withdrawal/sweep must be quarantined as REVIEW_REQUIRED and never broadcast.

Types

type Chain

type Chain interface {
	Balance(ctx context.Context, addr, denom string) (sdkmath.Int, error)
	Simulate(ctx context.Context, txBytes []byte) (*client.SimulateResult, error)
	Broadcast(ctx context.Context, txBytes []byte, mode client.BroadcastMode) (*client.BroadcastResult, error)
	Tx(ctx context.Context, hash string) (*client.TxInfo, error)
	NodeStatus(ctx context.Context) (*client.NodeStatus, error)
}

Chain is the subset of client.Client the workflow needs; *client.Client implementations and the failover wrapper satisfy it.

type Config

type Config struct {
	ChainID string

	// MinimumWithdrawalUsovr is an integer base-unit string.
	MinimumWithdrawalUsovr string
	// MaxFeeUsovr is an integer base-unit string bounding every fee under
	// both simulate policies.
	MaxFeeUsovr string
	// GasAdjustment is a decimal string (e.g. "1.3") applied to simulated
	// gas with ceiling rounding.
	GasAdjustment string
	// GasPriceUsovr is a decimal usovr-per-gas string (e.g. "0.025"); it
	// must be at or above the live x/globalfee floor.
	GasPriceUsovr string

	SimulateUnavailable SimulatePolicy
	// StaticGasLimit is required when SimulateUnavailable is SimulateStatic.
	StaticGasLimit uint64

	// BroadcastTimeout is the age after which a BROADCAST withdrawal whose
	// transaction cannot be found enters the unknown-after-timeout flow.
	BroadcastTimeout time.Duration
	// Confirmations is the depth required for CONFIRMED. The adapter passes the
	// shared scanner default (deposits.DefaultConfirmations = 2, range 1..12).
	Confirmations uint64

	// ProhibitedDestinations are strict-validation blocklist entries
	// (module accounts, sanctioned addresses).
	ProhibitedDestinations []string

	// KeyRefForSource maps a source address to the signer's opaque key
	// handle. Nil means the source address itself is the key ref.
	KeyRefForSource func(sourceAddress string) string
}

Config carries every economic value as a string: nothing here may be hard-coded in transaction logic (FR-040).

type Option

type Option func(*Workflow)

Option configures a Workflow.

func WithLogger

func WithLogger(l *slog.Logger) Option

WithLogger replaces the default logger.

func WithMetrics

func WithMetrics(s *metrics.Set) Option

WithMetrics attaches the adapter metric set.

type Outcome

type Outcome string

Outcome is one of the eight FR-035 broadcaster status distinctions.

const (
	// OutcomeEncodingFailed: the transaction could not be locally encoded.
	OutcomeEncodingFailed Outcome = "LOCAL_ENCODING_FAILED"
	// OutcomeSignatureFailed: the signature failed verification locally.
	OutcomeSignatureFailed Outcome = "SIGNATURE_FAILED"
	// OutcomeCheckTxRejected: node-side pre-inclusion rejection (CheckTx).
	OutcomeCheckTxRejected Outcome = "CHECKTX_REJECTED"
	// OutcomeMempoolAccepted: CheckTx passed; the tx is in the mempool.
	OutcomeMempoolAccepted Outcome = "MEMPOOL_ACCEPTED"
	// OutcomeIncluded: the tx is in a block; execution result not yet final
	// for the workflow (confirmation depth pending).
	OutcomeIncluded Outcome = "BLOCK_INCLUDED"
	// OutcomeExecSuccess: DeliverTx code 0 at the required depth.
	OutcomeExecSuccess Outcome = "EXECUTION_SUCCESS"
	// OutcomeExecFailed: included but DeliverTx code != 0 — reported
	// accurately, the transfer did NOT happen (fee was still deducted).
	OutcomeExecFailed Outcome = "EXECUTION_FAILED"
	// OutcomeUnknownTimeout: broadcast result unknown after timeout and the
	// original-tx search found nothing — quarantined, never re-signed.
	OutcomeUnknownTimeout Outcome = "UNKNOWN_AFTER_TIMEOUT"
)

type Request

type Request struct {
	WithdrawalID       string
	IdempotencyKey     string
	SourceAddress      string
	DestinationAddress string
	AmountBaseUnits    string
	Memo               string
}

Request is an exchange-side withdrawal instruction. AmountBaseUnits is an integer usovr string; the idempotency key comes from the exchange's upstream system (FR-033).

type SimulatePolicy

type SimulatePolicy string

SimulatePolicy selects the behaviour when the node cannot serve Simulate (contracts/adapter-config-and-ops.md `simulate_unavailable`).

const (
	// SimulateQueue holds withdrawals and alerts (the safe default).
	SimulateQueue SimulatePolicy = "queue"
	// SimulateStatic uses the configured static MsgSend gas, still bounded
	// by max_fee_usovr. Explicit opt-in.
	SimulateStatic SimulatePolicy = "static"
)

type Workflow

type Workflow struct {
	// contains filtered or unexported fields
}

Workflow drives withdrawal records through the §5 state machine.

func New

func New(store storage.Store, chain Chain, seq *sequences.Manager, sg signer.TransactionSigner, cfg Config, opts ...Option) (*Workflow, error)

New validates cfg and builds a Workflow.

func (*Workflow) ApproveCompliance

func (w *Workflow) ApproveCompliance(ctx context.Context, withdrawalID string) error

ApproveCompliance records the EXTERNALLY supplied compliance decision (FR-031): the kit blocks until this is called and never decides itself.

func (*Workflow) Broadcast

func (w *Workflow) Broadcast(ctx context.Context, withdrawalID string) (Outcome, error)

Broadcast pushes a SIGNED withdrawal's persisted bytes to the chain (FR-035). It never signs: only rec.SignedTxBytes ever reach the wire.

  • CheckTx rejection ⇒ SIGNED → REVIEW_REQUIRED with the node's code and log; the sequence reservation is quarantined. Not FAILED: the signed bytes persist and could still be accepted by another node, so the funds must stay committed (SumCommittedBySource counts a REVIEW_REQUIRED record that still holds signed bytes) — never auto-released.
  • Acceptance ⇒ BROADCAST (mempool accepted); inclusion and execution are Confirm's job.
  • Transport error/timeout ⇒ the unknown flow: search for the original tx by hash; found ⇒ treat as accepted; not found ⇒ withdrawal REVIEW_REQUIRED + reservation RECONCILIATION_REQUIRED. A second signed transaction for this withdrawal is impossible from any path here.

func (*Workflow) Build

func (w *Workflow) Build(ctx context.Context, withdrawalID string) error

Build moves SEQUENCE_RESERVED → TRANSACTION_BUILT after constructing the single-MsgSend transaction (FR-036) from the approved record.

func (*Workflow) Cancel

func (w *Workflow) Cancel(ctx context.Context, withdrawalID string) error

Cancel moves any pre-SIGNED withdrawal to CANCELLED, releasing its sequence reservation when one is bound and still RESERVED.

func (*Workflow) Confirm

func (w *Workflow) Confirm(ctx context.Context, withdrawalID string) (Outcome, error)

Confirm advances a BROADCAST or INCLUDED withdrawal from chain truth: inclusion ⇒ INCLUDED (height, code, log persisted; reservation CONSUMED); DeliverTx failure ⇒ FAILED with the accurate execution report; success at the configured depth ⇒ CONFIRMED. A BROADCAST withdrawal whose tx cannot be found after BroadcastTimeout enters the unknown flow (REVIEW_REQUIRED + reservation quarantine) — never a re-sign.

func (*Workflow) Recover

func (w *Workflow) Recover(ctx context.Context, withdrawalID string) (Outcome, error)

Recover drives a REVIEW_REQUIRED withdrawal that holds persisted signed bytes: search-first, then rebroadcast the identical bytes via the sequence manager (never re-sign). On success the withdrawal returns to the chain-truth status; the reservation leaves quarantine only when the transaction is observed on chain.

func (*Workflow) ReserveFunds

func (w *Workflow) ReserveFunds(ctx context.Context, withdrawalID string) error

ReserveFunds moves COMPLIANCE_APPROVED → FUNDS_RESERVED after the minimum check and the spendable-balance check (FR-032 items 3–4). The balance must cover this withdrawal's amount + max fee PLUS every amount + max fee already committed by other in-flight withdrawals from the same source. The committed-sum read, the balance check, and the status flip run inside a per-source critical section (the same account lock the sequence manager uses), so N individually-affordable concurrent reserves cannot each pass against the same raw balance and be signed for more than the wallet holds. The max-fee cap is the conservative per-withdrawal reservation — the true fee is unknown until simulation.

func (*Workflow) ReserveSequence

func (w *Workflow) ReserveSequence(ctx context.Context, withdrawalID string) error

ReserveSequence moves FUNDS_RESERVED → SEQUENCE_RESERVED with the single reservation bound to this withdrawal (FR-032 item 5, FR-034).

func (*Workflow) Sign

func (w *Workflow) Sign(ctx context.Context, withdrawalID string) error

Sign moves TRANSACTION_SIMULATED → SIGNED. It re-runs the FR-032 checklist tail against the exact sign-doc bytes: the summary derived from those bytes (tx.DeriveSummary inside SignDoc) must match the approved record field-for-field, including the chain ID. The signer's response is verified (signature valid over the doc, public key derives the sender) before anything is persisted; failure quarantines and never broadcasts.

func (*Workflow) Simulate

func (w *Workflow) Simulate(ctx context.Context, withdrawalID string) error

Simulate moves TRANSACTION_BUILT → TRANSACTION_SIMULATED, persisting gas_wanted (simulated), gas_limit (× adjustment, ceil) and the fee (gas_limit × gas price, ceil), bounded by max_fee_usovr (FR-032 items 6–7, FR-040). When the node cannot simulate, the configured simulate_unavailable policy applies: queue holds the withdrawal (ErrSimulationUnavailable), static uses the configured MsgSend gas.

func (*Workflow) Submit

func (w *Workflow) Submit(ctx context.Context, req Request) (storage.WithdrawalRecord, error)

Submit records a withdrawal in REQUESTED. A duplicate idempotency key returns the ORIGINAL record unchanged — a second signed or broadcast transaction for the same key is impossible from this layer down.

func (*Workflow) ValidateAddress

func (w *Workflow) ValidateAddress(ctx context.Context, withdrawalID string) error

ValidateAddress moves REQUESTED → ADDRESS_VALIDATED after strict destination validation including the prohibited set (FR-032 item 1).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL