Send To Many backport spec

Omni tx type 7 · target: TexitCoinCore 0.25.2 / Omni 0.9.1 · source: OmniLayer/omnicore v0.12.0.1

We do not rebase onto upstream v0.12.0.1 — that release sits on Bitcoin Core 0.20.1 and our tree is already on a 0.25.2 base, so a rebase would be a downgrade. We port type 7 into our tree instead. The surrounding machinery (payload encoders, the activation system, the wallet tx builder, the whole omni_send* family) already exists in our source, so this is additive.

00

First: the running node is not master HEAD

The live node reports /TexitCoinCore:0.25.2(Omni:0.9.1)/ and exposes only 43 Omni RPCs — payload builders, raw-tx helpers and getters. No omni_send, omni_sendall, omni_sendrawtx, omni_gettransaction.

But master in the repo does contain all of them — 85 omni_* registrations across rpctx.cpp and rpc.cpp. And wallets are loaded on the node (listwallets returns ["watch","hot2","hot"]), so this is not a runtime --disable-wallet.

The deployed binary was built from an older commit or a different branch than master. Step one is to rebuild master and re-probe — a chunk of the "missing" surface likely comes back for free, which shrinks the RPC work below.

01

Wire format (type 7, version 0)

offset  size  field
0       2     transaction version   uint16 BE  = 0
2       2     transaction type      uint16 BE  = 7
4       4     property id           uint32 BE
8       1     number of receivers   uint8
9       9*n   per receiver: output index (uint8) + amount (uint64 BE)

Minimum valid packet is 9 bytes. Receivers are referenced by output index in the funding transaction, not by address — the parser resolves index to address from the transaction's own outputs. 255 receivers is the encoding ceiling; OP_RETURN size and Class C packet chaining bite first.

02

Payload encoder

src/omnicore/createpayload.{h,cpp}

Ports verbatim — PUSH_BACK_BYTES and the SwapByteOrder* helpers already exist in our tree.

std::vector<unsigned char> CreatePayload_SendToMany(
    uint32_t propertyId,
    std::vector<std::tuple<uint8_t, uint64_t>> outputValues);
03

Parser and consensus

src/omnicore/tx.{h,cpp}

New state on CMPTransaction, reset in Init():

uint8_t numberOfSTMReceivers;
std::vector<std::tuple<uint8_t, uint64_t>> outputValuesForSTM;
std::map<uint8_t, std::string> validOutputAddressesForSTM;

Three dispatch sites take a case MSC_TYPE_SEND_TO_MANY: strTransactionType(), interpret_Transaction(), interpretPacket().

logicMath_SendToMany() validates every output first, then mutates balances in a second loop. A partial apply here is a consensus split.

-22  type/version not permitted at this block  (the activation gate)
-23  value zero or out of range
-24  property does not exist
-25  sender balance below total
-26  output index does not resolve to a destination
-27  non-fungible property, or empty receiver
-28  sum of per-output amounts != declared total
-29  valid receiver count != declared receiver count

The one piece that is not copy-paste: populating validOutputAddressesForSTM. Upstream fills it while walking vouts during transaction parsing; our 0.25.2 base has a different output-scanning loop (Litecoin address types, taproot-era ExtractDestination), so this hook has to be written against our parser. Keep the rules: key by vout index, skip the OP_RETURN payload outputs, skip anything that does not decode to a single valid destination, and never deduplicate — two outputs to the same address are two receivers.

04

Activation gate

src/omnicore/rules.{h,cpp}

const uint16_t FEATURE_SEND_TO_MANY = 19;
int MSC_SEND_TO_MANY_BLOCK;   // CConsensusParams

// restriction table
{ MSC_TYPE_SEND_TO_MANY, MP_TX_PKT_V0, false, MSC_SEND_TO_MANY_BLOCK },

// mainnet: 999999 (off until activated)   testnet/regtest: 0

Five sites in rules.cpp: the restriction table, the three network param blocks, and the ActivateFeature / DeactivateFeature / GetFeatureName / IsFeatureActivated switches.

This chain has never activated a feature — omni_getactivations returns empty. The activation mechanism itself is untested here, so it gets its own regtest pass.

05

RPC layer

src/omnicore/rpcpayload.cpp, rpctx.cpp

omni_createpayload_sendtomany propertyid [{"output": n, "amount": "x"}, ...]
omni_sendtomany "fromaddress" propertyid [{"address": "...", "amount": "..."}, ...]

The non-obvious part is the two-pass dry run: payload size determines how many OP_RETURN outputs exist, which determines the starting vout index for receivers. Build a throwaway payload, call GetDryPayloadOutputCount, then rebuild with real indices starting at that count.

The real wallet-side dependency is a WalletTxBuilder overload taking an ordered std::vector<std::string> of receiver addresses, one dust output each. Every existing omni_send* takes a single toAddress; nothing in our tree emits ordered receiver outputs yet, and that ordering is exactly what the payload's output indices point at. Get it wrong and funds land on the wrong addresses in a perfectly valid-looking transaction.

Also needed: ParseStmOutputIndex (rpcvalues) and RequireBoundedStmReceiverNumber (rpcrequirements).

First unit-test vector for the encoder, from upstream docs:

propertyid 31, [{2, "10.5"}, {3, "0.5"}, {5, "15.0"}]
-> 000000070000001f0302000000003e95ba80030000000002faf080050000000059682f00

   0000      version 0
   0007      type 7
   0000001f  property 31
   03        3 receivers
   02 000000003e95ba80   vout 2, 10.5
   03 0000000002faf080   vout 3, 0.5
   05 0000000059682f00   vout 5, 15.0
06

Regtest validation

  • Issue a managed property, grant supply, send to 2 receivers, verify balances.
  • Round-trip: omni_getpayload against omni_createpayload_sendtomany.
  • Scale to 50 and 255 receivers; record where chaining actually breaks.
  • One negative test per error code above, especially -28 and -26.
  • Activation dry run with mainnet-like params: reject with -22, broadcast activation for feature 19, mine past the height, confirm it succeeds.
  • Run old and new binaries side by side over the same chain pre-activation — omni_getcurrentconsensushash must match. That proves the patch is inert until we say go.
07

Mainnet rollout

Activation is a consensus change. Any node, explorer or exchange integration left on the old binary past the activation height will compute wrong balances for any address that receives a type-7 send. Before a height gets picked we need an inventory of every node and service on the network, and min_client_version set on the activation transaction so old clients refuse to follow rather than silently diverging. Two weeks of blocks minimum between announcement and activation.

Full spec with the complete code blocks lives in the repo at docs/send-to-many-backport.md. Build instructions: texitcoin.org/build.