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 ¶
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 ¶
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.
var ErrBodyTooLarge = errors.New("publish: response body exceeds the bound")
ErrBodyTooLarge refuses a response above the bound.
var ErrNoEntry = errors.New("publish: no journal entry")
ErrNoEntry is returned by Read and Update for a sequence that has no file.
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 ¶
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) Ping ¶
Ping reads GET /policy, which answers without a transaction, to show the installation is reachable and speaks ARC.
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 ¶
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 ¶
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.
type RPCSettler ¶
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.