publish

package
v0.5.4 Latest Latest
Warning

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

Go to latest
Published: Oct 1, 2026 License: Apache-2.0 Imports: 19 Imported by: 0

Documentation

Overview

Package publish is the publisher's dual submission: one signed transaction, two encodings, two transports, and never one writer for both.

The settlement leg (TCPIngress, RPCSettler, Arcade) carries a mined transaction as EF bytes to a fabric ingress, or as standard hex to a node's RPC. The object leg (Facade) carries a BEEF to an overlay host's submit door. The shard-proxy locks a TCP connection's grammar from its first four bytes (0xBE 0xEF is a BRC-149 stream, E3 E1 F3 E8 is framed, anything else is bare transactions), so a BEEF written down the EF socket is not a mistake the peer reports, it is a poisoned stream. This package makes that a structural rule rather than a prose one: the two legs share no connection, no net.Conn and no writer, and this package exports nothing that takes both a *transaction.Transaction and a BEEF []byte. TestNoExportedAPITakesBoth walks the package's exported declarations to hold it, and TestLegsNeverShareASocket runs both legs against recording peers.

Index

Examples

Constants

View Source
const DefaultVerdict = 15 * time.Second

DefaultVerdict is long enough for a network to see a transaction in the ordinary case and short enough that a publisher never waits long for it.

Variables

View Source
var ErrArcadeUnknown = errors.New("arcade: transaction not known")

ErrArcadeUnknown is a txid arcade does not hold. A transaction broadcast through some other path is not arcade's to report on.

View Source
var ErrBodyTooLarge = errors.New("publish: response body exceeds the bound")

ErrBodyTooLarge refuses a response above the bound.

View Source
var ErrNoEntry = errors.New("publish: no journal entry")

ErrNoEntry is returned by Read and Update for a sequence that has no file.

View Source
var ErrNotBEEF = errors.New("publish: body is not a BEEF")

ErrNotBEEF is returned by Submit for a body that does not start with a BEEF marker. Nothing else may go down the object leg.

Functions

func IsBEEF

func IsBEEF(b []byte) bool

IsBEEF reports whether b begins with a BEEF V1, V2 or Atomic BEEF marker (the SDK's constants, little-endian on the wire).

Types

type Arcade

type Arcade struct {
	Base string
	// Key, when set, is sent as a bearer token; public arcade installations
	// generally require one.
	Key    string
	Client *http.Client
	// Verdict bounds how long Submit waits for the network to accept or
	// refuse a transaction arcade has taken. Zero means DefaultVerdict.
	Verdict time.Duration
	// Poll is the interval between status checks while waiting. Zero means
	// one second.
	Poll time.Duration
	// Note, when set, receives a line a caller may want to show: a verdict
	// that did not arrive inside the bound, or a backoff.
	Note func(format string, args ...any)
	// Asset, when set, is the node arcade's verdict is held to. Arcade has
	// answered ACCEPTED_BY_NETWORK for a transaction whose input was already
	// spent and mined; with Asset set, an input the node shows spent by
	// another transaction is a refusal (errors.Is nodeapi.ErrDoubleSpent)
	// whatever arcade says, and is checked while Submit waits too.
	Asset *nodeapi.Asset
}

Arcade settles through an arcade installation's ARC-compatible API: the broadcaster a publisher reaches when it runs no node of its own.

Unlike the bare EF ingress it answers. POST /tx validates policy synchronously and says so, and GET /tx/{txid} reports the network's verdict and, once mined, the merkle path. That answer is what lets a publisher stop waiting for a block: the network's acceptance is the evidence a transaction will mine, and the proof can be collected later.

Only a transaction meant to be mined is ever given to a Settler. A carrier is non-final by construction and arcade would refuse it as such; nothing here ever sees one.

func (*Arcade) Name

func (a *Arcade) Name() string

Name identifies the leg in the journal.

func (*Arcade) Ping

func (a *Arcade) Ping(ctx context.Context) error

Ping reads GET /policy, which answers without a transaction, to show the installation is reachable and speaks ARC.

func (*Arcade) Status

func (a *Arcade) Status(ctx context.Context, txid string) (*ArcadeStatus, error)

Status is GET /tx/{txid}.

func (*Arcade) Submit

func (a *Arcade) Submit(ctx context.Context, tx *transaction.Transaction) error

Submit gives arcade the transaction as EF and waits, briefly, for the network's verdict.

A 202 means arcade's policy check passed; scripts and fees are the network's to judge, so RECEIVED alone is not yet acceptance. Submit waits up to Verdict for the network to accept or refuse. A refusal is an error, which keeps a transaction the network will not mine from being published to anyone. No verdict inside the bound is not an error: the transaction is in arcade's hands and its later status will say, and a publisher that waited for it would be back to waiting for a block.

With Asset set, acceptance is not taken at arcade's word: an input the node shows spent by another transaction is the network's refusal, an error that wraps a *nodeapi.SpentError.

type ArcadeStatus

type ArcadeStatus struct {
	TxID         string   `json:"txid"`
	TxStatus     string   `json:"txStatus"`
	BlockHash    string   `json:"blockHash"`
	BlockHeight  uint32   `json:"blockHeight"`
	MerklePath   string   `json:"merklePath"`
	ExtraInfo    string   `json:"extraInfo"`
	CompetingTxs []string `json:"competingTxs"`
}

ArcadeStatus is arcade's answer for one transaction.

func (*ArcadeStatus) Accepted

func (s *ArcadeStatus) Accepted() bool

Accepted reports that the network, not only arcade's policy check, has taken the transaction.

func (*ArcadeStatus) Mined

func (s *ArcadeStatus) Mined() bool

Mined reports a transaction arcade can prove.

func (*ArcadeStatus) Refused

func (s *ArcadeStatus) Refused() bool

Refused reports a terminal refusal: the network will not mine this transaction as it stands.

func (*ArcadeStatus) Why

func (s *ArcadeStatus) Why() string

Why is the refusal in words, with whatever arcade said about it.

type Entry

type Entry struct {
	// Acct is the publisher's account, such as a BRC-169 handle@domain, as
	// the application writes it.
	Acct string `json:"acct"`
	// IdentityKey is the publisher's identity key, in the application's
	// encoding.
	IdentityKey string `json:"identityKey"`
	// Seq is the transition's sequence number, the first half of the key.
	Seq uint64 `json:"seq"`
	// Kind is the application's label for the transition.
	Kind string `json:"kind"`
	// TxID is the id of the transaction the settlement leg sends, 64 hex
	// characters, the second half of the key.
	TxID string `json:"txid"`
	// CarrierTxID is the id of the carrier the transition commits to, when
	// it has one.
	CarrierTxID string `json:"carrierTxid,omitempty"`
	// EFSentAt is when the settlement leg took the transaction, and EFError
	// why it did not.
	EFSentAt *time.Time `json:"efSentAt,omitempty"`
	EFError  string     `json:"efError,omitempty"`
	// BEEFSentAt is when the object leg's host answered, BEEFSteak that
	// answer as received, and BEEFError why the leg failed.
	BEEFSentAt *time.Time      `json:"beefSentAt,omitempty"`
	BEEFSteak  json.RawMessage `json:"beefSteak,omitempty"`
	BEEFError  string          `json:"beefError,omitempty"`
	// MinedAt is when the transaction was seen mined, and Height the block
	// height it was mined at.
	MinedAt *time.Time `json:"minedAt,omitempty"`
	Height  uint32     `json:"height,omitempty"`
}

Entry is one transition's record. The journal reads only Seq and TxID, which key the entry; every other field holds what the caller writes.

type Facade

type Facade struct {
	// Base is the facade's root with no path suffix.
	Base string
	// HTTP is optional; the default has a 30s timeout.
	HTTP *http.Client
}

Facade is the object leg: POST <Base>/submit with a raw BEEF body and one topic. It owns its own *http.Client and touches no net.Conn of the settlement leg.

The POST is hand-rolled rather than go-sdk's HTTPSOverlayBroadcastFacilitator because that one sets x-topics to a JSON array, which not every overlay server reads: go-overlay-services before v1.3.6 splits the header on commas without trimming, so the brackets and quotes stay in the name and no topic matches. It also mis-decodes the wrapped answer. One bare name reads the same on every server.

func (*Facade) Submit

func (f *Facade) Submit(ctx context.Context, topic string, beef []byte) (Result, error)

Submit posts beef to the facade for exactly one topic.

One topic per call, because a Result is one topic's answer. Submit refuses an empty topic, or one containing a comma, space, tab, CR or LF, before it sends anything: a server splits x-topics on commas, and servers differ on whether they trim the parts, so "a, b" can reach the topic lookup as "a" and " b". The header is sent exactly as given.

Example

The object leg carries a BEEF and nothing else. Facade.Submit checks the marker and the topic before it builds a request, so the refusals below never reach the network (overlay.example.com is never contacted).

package main

import (
	"context"
	"errors"
	"fmt"

	"github.com/bsv-blockchain/go-sdk/chainhash"
	"github.com/bsv-blockchain/go-sdk/script"
	"github.com/bsv-blockchain/go-sdk/transaction"

	"github.com/lightwebinc/bcommon/publish"
)

func main() {
	tx := transaction.NewTransaction()
	nowhere := chainhash.Hash{}
	tx.AddInput(&transaction.TransactionInput{SourceTXID: &nowhere, UnlockingScript: &script.Script{},
		SequenceNumber: transaction.MaxTxInSequenceNum})
	tx.AddOutput(&transaction.TransactionOutput{Satoshis: 1, LockingScript: &script.Script{script.OpTRUE}})
	isTxid := true
	tx.MerklePath = transaction.NewMerklePath(1, [][]*transaction.PathElement{{
		{Offset: 0, Hash: tx.TxID(), Txid: &isTxid},
		{Offset: 1, Duplicate: &isTxid},
	}})
	beef, err := tx.BEEF()
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println("BEEF:", publish.IsBEEF(beef), "raw transaction:", publish.IsBEEF(tx.Bytes()))

	f := &publish.Facade{Base: "https://overlay.example.com"}
	ctx := context.Background()
	_, err = f.Submit(ctx, "tm_example", tx.Bytes())
	fmt.Println(errors.Is(err, publish.ErrNotBEEF))
	_, err = f.Submit(ctx, "tm_example,tm_other", beef)
	fmt.Println(err)
}
Output:
BEEF: true raw transaction: false
true
publish: topic "tm_example,tm_other" must be one name with no separators

type Journal

type Journal struct {
	// Dir holds the entries; Write creates it when it is missing.
	Dir string
}

Journal keeps one record per transition of a publish's two legs, so what happened to a transition outlives the process that sent it.

Each entry is one file, <seq>-<txid>.json, mode 0600, written atomically, so an entry's update never rewrites another entry. The key is (seq, txid) rather than seq alone because a sequence retried after a failure leaves one entry per attempt. List refuses a .json file that is not a readable <seq>-<txid> entry rather than skip it, and Update may not change an entry's key.

func (Journal) List

func (j Journal) List() ([]Entry, error)

List returns every entry sorted by sequence, then txid. Directories, dotfiles (a write in progress) and names without a .json suffix are not entries and are passed over. A .json file that is not a readable <seq>-<txid> entry is an ERROR, not a skip: a stranded sequence is exactly the one an operator needs shown, and a diagnostic that quietly skipped the file it could not parse would report a clean history over a broken one.

func (Journal) Read

func (j Journal) Read(seq uint64, txid string) (Entry, error)

Read returns one entry.

func (Journal) Update

func (j Journal) Update(seq uint64, txid string, mutate func(*Entry)) error

Update reads the entry, applies mutate and writes it back.

func (Journal) Write

func (j Journal) Write(e Entry) error

Write creates or replaces the entry for (e.Seq, e.TxID).

type RPCSettler

type RPCSettler struct {
	RPC *nodeapi.RPC
}

RPCSettler sends the standard serialisation through sendrawtransaction and returns the node's answer. This leg has an acknowledgement, which the TCP ingress does not.

func (*RPCSettler) Name

func (r *RPCSettler) Name() string

Name identifies the leg in the journal.

func (*RPCSettler) Submit

func (r *RPCSettler) Submit(ctx context.Context, tx *transaction.Transaction) error

Submit sends tx.Hex() and returns the node's error verbatim. A txid that comes back different from the one we computed is an error too: it would mean the node parsed different bytes than we signed.

type Result

type Result struct {
	// Admitted are the output indexes the topic manager admitted.
	Admitted []uint32
	// Retained are the input outpoints (as indexes) the manager kept.
	Retained []uint32
	// Duplicate is HTTP 200 with nothing admitted: the engine's answer for
	// an object it already holds. It is a result, not an error, so a
	// re-POST after a crash is idempotent.
	Duplicate bool
	// Raw is the whole response body, for the journal.
	Raw json.RawMessage
}

Result is what the host answered for the one topic submitted.

type Settler

type Settler interface {
	Submit(ctx context.Context, tx *transaction.Transaction) error
	Name() string
}

Settler delivers a signed transaction for mining. It takes the transaction itself so that a Settler can never be handed a BEEF: the only bytes a Settler ever writes are ones it encoded from a *transaction.Transaction.

type TCPIngress

type TCPIngress struct {
	// Addr is host:port of the ingress (8725 on a shard-proxy).
	Addr string
	// DialTimeout defaults to 5s.
	DialTimeout time.Duration
	// WriteTimeout defaults to 10s and is overridden by a ctx deadline.
	WriteTimeout time.Duration
	// contains filtered or unexported fields
}

TCPIngress writes the transaction's EF (BRC-30) bytes to a fabric ingress as a bare transaction stream. There is no acknowledgement: the journal and a later WaitMined are the evidence.

func (*TCPIngress) Name

func (t *TCPIngress) Name() string

Name identifies the leg in the journal.

func (*TCPIngress) Submit

func (t *TCPIngress) Submit(ctx context.Context, tx *transaction.Transaction) error

Submit dials a fresh connection, writes the EF bytes in one Write and closes. One Write because the stream is self-delimiting: the reader parses transaction structure with no length prefix, so a short write leaves it mid-transaction and everything after is garbage. A fresh connection per Submit because a poisoned stream then has nothing after it to poison; the cost is one handshake per transition, and transitions are rare.

Jump to

Keyboard shortcuts

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