PushDrop
PushDrop is the token format that BRC-100 overlay services use. A PushDrop Output holds a list of data fields, and is locked to a Public Key. The fields are pushed onto the stack and then dropped from it again, so spending the Output takes nothing more than a Signature by that key.
<lockingPublicKey> OP_CHECKSIG <field 1> ... <field n> OP_2DROP ... [OP_DROP]Unlike an OP_FALSE OP_RETURN Output (see Data in Script), a PushDrop Output is spendable. That is the whole point. The owner can replace a record by spending the old Output and making a new one, and an overlay drops the old record once it is spent. Message box host advertisements, and the SHIP and SLAP advertisements that overlay hosts publish, are all PushDrop tokens.
libtokensv writes and reads these Scripts byte for byte as @bsv/sdk’s PushDrop template does (its tests check against vectors that the SDK wrote).
Locking and decoding
Section titled “Locking and decoding”PushDrop.lock makes the Locking Script from a compressed Public Key (in hex) and the fields:
final script = PushDrop.lock( lockingPublicKey: owner.publicKey.toHex(), // compressed, hex fields: [utf8.encode('hello'), utf8.encode('world'), [1, 2, 3]],);// <key> OP_CHECKSIG <hello> <world> <010203> OP_2DROP OP_DROPexpect(script.toString(), contains('OP_CHECKSIG'));PushDrop.decode reads one back. It returns null for any Script that is not exactly a PushDrop Script, i.e. one key, OP_CHECKSIG, at least one pushed field, and drops that remove exactly those fields.
final PushDropToken? token = PushDrop.decode(script);expect(token!.lockingPublicKey, owner.publicKey.toHex());expect(utf8.decode(token.fields[0]), 'hello');expect(token.fields[2], [1, 2, 3]);
// Anything that is not exactly a PushDrop Script decodes to null.final p2pkh = P2PKHLockBuilder.fromAddress(owner.toAddress(networkType: NetworkType.TEST)).getScriptPubkey();expect(PushDrop.decode(p2pkh), isNull);Fields are pushed in their minimal encoding, as the SDK does. A one-byte field of 1 to 16 becomes OP_1 to OP_16, and an empty field or a single zero byte becomes OP_0. Both of these decode back to a one-byte field, which means an empty field reads back as [0].
The lock can also go after the fields. In that case we pass the same position to decode:
final script = PushDrop.lock(lockingPublicKey: key, fields: [[1, 2, 3]], position: LockPosition.after);expect(PushDrop.decode(script), isNull); // read with the default positionexpect(PushDrop.decode(script, position: LockPosition.after)!.fields, [[1, 2, 3]]);PushDropLockBuilder and PushDropUnlockBuilder are the same Scripts packaged as dartsv lock and unlock builders, for when we would rather build Transactions ourselves with dartsv’s TransactionBuilder.
In a wallet
Section titled “In a wallet”PushDropPlugin lets a libspiffy wallet make and spend PushDrop tokens. We register it with the wallet’s network:
// In the isolate the libspiffy wallet runs in, before it loads a wallet.final plugin = PushDropPlugin(network: NetworkType.TEST);PluginRegistry().register(plugin);addTearDown(() => PluginRegistry().unregister(PushDropPlugin.id));The examples below call buildTransaction directly, with a stand-in for the request that libspiffy builds (a funding coin, its signer, a fee rate and a key lookup). In an app you would instead pass the spec to the wallet’s PayInvoiceCommand, and libspiffy does the rest.
Making a token
Section titled “Making a token”PushDropPlugin.outputSpec turns the tokens we want to make (and spend) into the output spec of a payment. The wallet funds the fee and takes the change. Each token Output holds 1 satoshi, unless we give PushDropOutput another amount.
final make = PushDropPlugin.outputSpec(outputs: [PushDropOutput(lockingScript: advert('https://a.example.org'))]);// In an app: PayInvoiceCommand(..., outputs: [make]). The wallet funds// the fee and takes the change.final made = (await plugin.buildTransaction(walletRequest(make))).primaryTx;expect(made.outputs.first.satoshis, BigInt.one); // the tokenThe spec’s amount is the tokens’ satoshis plus an allowance for the bytes that the wallet’s coin selection does not see (the token Outputs, the token Inputs and the change). Whatever the Transaction does not need comes back as change.
Replacing a token
Section titled “Replacing a token”Replacing a record means spending the old token and making the new one, in a single Transaction. The old token’s Input is signed with its own locking key, which the wallet looks up by its hash:
// To replace a token, we spend the old one and make the new one, in one Transaction.final old = made.outputs.first.script;final replace = PushDropPlugin.outputSpec( outputs: [PushDropOutput(lockingScript: advert('https://b.example.org'))], spends: [PushDropSpend(txid: made.id, vout: 0, satoshis: BigInt.one, lockingScript: old)],);final replaced = (await plugin.buildTransaction(walletRequest(replace))).primaryTx;verifies(replaced, 0, old, 1); // signed with the token's own keyWithdrawing a token
Section titled “Withdrawing a token”To withdraw a token we spend it and make none. The spec’s action is then spend:
// To withdraw a token, we spend it and make none.final withdraw = PushDropPlugin.outputSpec( spends: [PushDropSpend(txid: replaced.id, vout: 0, satoshis: BigInt.one, lockingScript: replaced.outputs.first.script)]);expect(withdraw.params['action'], PushDropPlugin.spend);final withdrawn = (await plugin.buildTransaction(walletRequest(withdraw))).primaryTx;expect(withdrawn.outputs.where((o) => PushDrop.decode(o.script) != null), isEmpty);The wallet’s view of its tokens
Section titled “The wallet’s view of its tokens”The plugin identifies PushDrop Outputs, and names each one’s owner as the Address of its locking key. The wallet uses this to attribute the Output. A token locked to one of the wallet’s keys is the wallet’s, and is tracked, but it is never counted in the balance or spent as change.
final script = advert('https://a.example.org');expect(plugin.identifyScript(script), PushDropPlugin.scriptType);final metadata = plugin.extractMetadata(script)!;expect(metadata['ownerAddress'], tokenKey.toAddress(networkType: NetworkType.TEST).toBase58());expect(metadata['fields'], [identity.identityKey, hex.encode(utf8.encode('https://a.example.org'))]);The wallet can only spend a token that is locked to a key it holds. That is either one of its HD keys, or a BRC-42 (type-42) key it has recorded with the coordinator’s RecordType42AddressesCommand (libspiffy 5.5.0 and later; see libspiffy.dev). For any other key, buildTransaction fails with a StateError from the key lookup.
Signed fields, and the key a token is locked to
Section titled “Signed fields, and the key a token is locked to”BRC-100 tokens usually carry a Signature over their fields, made with a BRC-42 child of the wallet’s identity key, and the reference client locks the token to that very same child key. A libspiffy wallet will not do both. It refuses to sign data with a key that it holds coins under, because a Signature over data could be a spend Signature. So once the locking key is recorded as the wallet’s, it can no longer sign the fields of the next token.
The way around this is to lock the token to a different child of the identity than the one that signs the fields. Overlays check the field Signature, not the lock. For a message box advertisement, the fields are signed with protocol [1, 'messagebox advertisement'], key 1, counterparty anyone (as the tm_messagebox topic manager verifies), and the token is locked to the same protocol and key with counterparty self:
// A BRC-100 identity (the wallet's identity key) and a message box// advertisement's protocol, as @bsv/sdk names them.final identity = Brc100Keys(SVPrivateKey.fromHex('22' * 32, NetworkType.TEST));final protocol = Brc43Protocol(1, 'messagebox advertisement');final fields = [hex.decode(identity.identityKey), utf8.encode('https://messagebox.example.org')];// The overlay checks this Signature, made by the identity's child for// counterparty "anyone".final signature = identity.createSignature(protocol, '1', fields.expand((f) => f).toList(), counterparty: Brc100Counterparty.anyone);// The token is locked to the child for counterparty "self", which the// wallet records as one of its own keys (see below).final lockingKey = identity.derivePublicKey(protocol, '1', Brc100Counterparty.self, forSelf: true);final advert = PushDrop.lock(lockingPublicKey: lockingKey.toHex(), fields: [...fields, signature]);We then record the locking key in the wallet as a type-42 derivation, with the wallet’s anchor key as both anchor and sender, and the invoice number 1-messagebox advertisement-1. Both keys derive from the identity, so a wallet restored from its phrase finds its tokens again.
Brc100Keys,Brc43ProtocolandBrc100Counterpartycome from libspiffy (package:libspiffy/libspiffy.dart).
Publishing to an overlay
Section titled “Publishing to an overlay”To have an overlay index a token, we submit the Transaction’s BEEF to the overlay’s topic with OverlayClient.

