Skip to main content

bsv/script/templates/
push_drop.rs

1//! PushDrop script template for embedding data in Bitcoin scripts.
2//!
3//! PushDrop creates scripts that embed arbitrary data fields followed by
4//! OP_DROP operations to clean the stack, then lock with OP_CHECKSIG.
5//! This enables data storage on-chain while maintaining spending control.
6//!
7//! Supports two lock positions matching the TS SDK:
8//! - **Before** (default): `<pubkey> OP_CHECKSIG <fields...> OP_2DROP... OP_DROP`
9//! - **After**: `<fields...> OP_2DROP... OP_DROP <pubkey> OP_CHECKSIG`
10//!
11//! # This template is wallet-driven, not private-key-driven
12//!
13//! [`PushDrop`] holds a [`WalletInterface`], exactly as `@bsv/sdk`'s
14//! `PushDrop` (TS) and `go-sdk`'s `pushdrop.PushDrop` (Go) do. The locking key is
15//! a **BRC-42 derived child key** obtained via `getPublicKey`, and the signature
16//! field is produced via `createSignature` — never from a raw local key.
17//!
18//! An earlier revision of this port took a `PrivateKey` and locked to the RAW
19//! public key, omitted the signature field entirely, and shipped a `lock_only()`
20//! constructor that could never produce a script (it left the key `None`, which
21//! `lock()` then rejected). That shape exists in neither reference SDK, and the
22//! scripts it produced were not interoperable. It is gone.
23//!
24//! Deriving from the wallet is also what lets an MPC- or HSM-backed wallet own
25//! the spending key: the template never sees private material.
26
27use crate::primitives::hash::sha256;
28use crate::primitives::public_key::PublicKey;
29use crate::primitives::transaction_signature::{SIGHASH_ALL, SIGHASH_FORKID};
30use crate::script::error::ScriptError;
31use crate::script::locking_script::LockingScript;
32use crate::script::op::Op;
33use crate::script::script::Script;
34use crate::script::script_chunk::ScriptChunk;
35use crate::script::unlocking_script::UnlockingScript;
36use crate::wallet::interfaces::{CreateSignatureArgs, GetPublicKeyArgs, WalletInterface};
37use crate::wallet::types::{Counterparty, Protocol};
38
39/// Lock position for the public key in the PushDrop script.
40#[derive(Clone, Copy, Debug, Default, PartialEq)]
41pub enum LockPosition {
42    /// `<pubkey> OP_CHECKSIG <fields...> OP_2DROP...` (TS default)
43    #[default]
44    Before,
45    /// `<fields...> OP_2DROP... <pubkey> OP_CHECKSIG`
46    After,
47}
48
49/// The result of decoding a PushDrop locking script.
50///
51/// Mirrors TS `PushDrop.decode` / Go `pushdrop.Decode`, both of which return the
52/// locking public key ALONGSIDE the fields. The old Rust port returned a keyless
53/// `PushDrop`, discarding the pubkey.
54#[derive(Clone, Debug)]
55pub struct PushDropData {
56    /// The public key the output is locked to.
57    pub locking_public_key: PublicKey,
58    /// The embedded data fields, with minimally-encoded opcode forms decoded back
59    /// to their byte values.
60    pub fields: Vec<Vec<u8>>,
61}
62
63/// PushDrop script template for embedding data with spending control.
64///
65/// Holds a [`WalletInterface`] (see the module docs); the locking key is derived
66/// per `(protocol_id, key_id, counterparty)`, never supplied directly.
67/// The wallet is BORROWED. Go stores a `wallet.Interface` (an interface value,
68/// i.e. a pointer); a borrow is the Rust analogue and keeps the template usable
69/// from a store that owns its wallet (`&self.wallet`) and from one that holds it
70/// behind an `Arc` (`&*arc`) alike, with no clone and no `Arc` requirement.
71pub struct PushDrop<'a, W: WalletInterface + ?Sized> {
72    /// The wallet that derives keys and produces signatures.
73    pub wallet: &'a W,
74    /// Originator passed through on every wallet request.
75    pub originator: Option<String>,
76}
77
78impl<'a, W: WalletInterface + ?Sized> PushDrop<'a, W> {
79    /// Construct a PushDrop template bound to `wallet`.
80    pub fn new(wallet: &'a W, originator: Option<String>) -> Self {
81        Self { wallet, originator }
82    }
83
84    /// Create a PushDrop locking script.
85    ///
86    /// Port of TS `PushDrop.lock` / Go `PushDrop.Lock`, in that order of authority.
87    ///
88    /// - The locking pubkey is derived: `getPublicKey({protocol_id, key_id, counterparty, for_self})`.
89    /// - When `include_signature` (the TS/Go default is **true**), a
90    ///   `createSignature` over the CONCATENATED fields is appended **as an extra
91    ///   field** — so it participates in the OP_2DROP/OP_DROP tail count.
92    /// - Fields are minimally encoded (see [`make_data_push`]).
93    #[allow(clippy::too_many_arguments)]
94    pub async fn lock(
95        &self,
96        mut fields: Vec<Vec<u8>>,
97        protocol_id: Protocol,
98        key_id: &str,
99        counterparty: Counterparty,
100        for_self: bool,
101        include_signature: bool,
102        lock_position: LockPosition,
103    ) -> Result<LockingScript, ScriptError> {
104        if fields.is_empty() && !include_signature {
105            return Err(ScriptError::InvalidScript(
106                "PushDrop: at least one data field required".into(),
107            ));
108        }
109
110        let pk = self
111            .wallet
112            .get_public_key(
113                GetPublicKeyArgs {
114                    identity_key: false,
115                    protocol_id: Some(protocol_id.clone()),
116                    key_id: Some(key_id.to_string()),
117                    counterparty: Some(counterparty.clone()),
118                    privileged: false,
119                    privileged_reason: None,
120                    for_self: Some(for_self),
121                    seek_permission: None,
122                },
123                self.originator.as_deref(),
124            )
125            .await
126            .map_err(|e| ScriptError::InvalidScript(format!("PushDrop lock: getPublicKey: {e}")))?;
127
128        let pubkey_bytes = pk.public_key.to_der();
129        let mut lock_chunks = vec![
130            ScriptChunk::new_raw(pubkey_bytes.len() as u8, Some(pubkey_bytes)),
131            ScriptChunk::new_opcode(Op::OpCheckSig),
132        ];
133
134        if include_signature {
135            // Signed data is the concatenation of the fields, BEFORE the signature
136            // itself is appended (TS `fields.reduce`, Go's `dataToSign` loop).
137            let data_to_sign: Vec<u8> = fields.concat();
138            let sig = self
139                .wallet
140                .create_signature(
141                    CreateSignatureArgs {
142                        protocol_id: protocol_id.clone(),
143                        key_id: key_id.to_string(),
144                        counterparty: counterparty.clone(),
145                        data: Some(data_to_sign),
146                        hash_to_directly_sign: None,
147                        privileged: false,
148                        privileged_reason: None,
149                        seek_permission: None,
150                    },
151                    self.originator.as_deref(),
152                )
153                .await
154                .map_err(|e| {
155                    ScriptError::InvalidScript(format!("PushDrop lock: createSignature: {e}"))
156                })?;
157            fields.push(sig.signature);
158        }
159
160        let mut push_drop_chunks: Vec<ScriptChunk> =
161            fields.iter().map(|f| make_data_push(f)).collect();
162
163        // Drop tail. Counted over the fields INCLUDING the appended signature.
164        let mut not_yet_dropped = fields.len();
165        while not_yet_dropped > 1 {
166            push_drop_chunks.push(ScriptChunk::new_opcode(Op::Op2Drop));
167            not_yet_dropped -= 2;
168        }
169        if not_yet_dropped != 0 {
170            push_drop_chunks.push(ScriptChunk::new_opcode(Op::OpDrop));
171        }
172
173        let chunks = match lock_position {
174            LockPosition::Before => {
175                lock_chunks.extend(push_drop_chunks);
176                lock_chunks
177            }
178            LockPosition::After => {
179                push_drop_chunks.extend(lock_chunks);
180                push_drop_chunks
181            }
182        };
183
184        Ok(LockingScript::from_script(Script::from_chunks(chunks)))
185    }
186
187    /// Produce the unlocking script for a PushDrop output, signing `preimage`
188    /// (the BIP-143/BSV sighash preimage) through the wallet.
189    ///
190    /// Matches TS/Go: the wallet is handed `sha256(preimage)` as `data`, and
191    /// hashes once more internally — so the signed digest is `sha256d(preimage)`,
192    /// the correct BSV sighash. (The previous private-key implementation signed a
193    /// SINGLE sha256 of the preimage, which is not a valid BSV sighash.)
194    pub async fn unlock(
195        &self,
196        preimage: &[u8],
197        protocol_id: Protocol,
198        key_id: &str,
199        counterparty: Counterparty,
200        sighash_type: u8,
201    ) -> Result<UnlockingScript, ScriptError> {
202        let preimage_hash = sha256(preimage);
203
204        let sig = self
205            .wallet
206            .create_signature(
207                CreateSignatureArgs {
208                    protocol_id,
209                    key_id: key_id.to_string(),
210                    counterparty,
211                    data: Some(preimage_hash.to_vec()),
212                    hash_to_directly_sign: None,
213                    privileged: false,
214                    privileged_reason: None,
215                    seek_permission: None,
216                },
217                self.originator.as_deref(),
218            )
219            .await
220            .map_err(|e| {
221                ScriptError::InvalidScript(format!("PushDrop unlock: createSignature: {e}"))
222            })?;
223
224        let mut sig_bytes = sig.signature;
225        sig_bytes.push(sighash_type);
226
227        let chunks = vec![ScriptChunk::new_raw(sig_bytes.len() as u8, Some(sig_bytes))];
228        Ok(UnlockingScript::from_script(Script::from_chunks(chunks)))
229    }
230
231    /// The default sighash scope: `SIGHASH_ALL | SIGHASH_FORKID`.
232    pub fn default_sighash_type() -> u8 {
233        (SIGHASH_ALL | SIGHASH_FORKID) as u8
234    }
235
236    /// Estimate the byte length of the unlocking script (TS/Go both answer 73).
237    pub fn estimate_unlock_length() -> usize {
238        73
239    }
240}
241
242/// Decode a PushDrop locking script in the default (`Before`) position.
243///
244/// A free function, mirroring Go's package-level `pushdrop.Decode`. Decoding does
245/// not involve a wallet, so it must not be bound to the wallet-generic
246/// [`PushDrop`] type — otherwise every call site needs a meaningless turbofish.
247pub fn decode(script: &LockingScript) -> Result<PushDropData, ScriptError> {
248    decode_with_position(script, LockPosition::Before)
249}
250
251/// Decode a PushDrop locking script, recovering pubkey + data fields.
252pub fn decode_with_position(
253    script: &LockingScript,
254    position: LockPosition,
255) -> Result<PushDropData, ScriptError> {
256    let chunks = script.chunks();
257    if chunks.len() < 3 {
258        return Err(ScriptError::InvalidScript(
259            "PushDrop::decode: script too short".into(),
260        ));
261    }
262
263    match position {
264        LockPosition::Before => decode_before(chunks),
265        LockPosition::After => decode_after(chunks),
266    }
267}
268
269impl PushDropData {
270    /// Convenience alias for [`decode`].
271    pub fn decode(script: &LockingScript) -> Result<PushDropData, ScriptError> {
272        decode(script)
273    }
274}
275
276/// Decode "before" layout: `<pubkey> OP_CHECKSIG <fields...> OP_2DROP...`
277fn decode_before(chunks: &[ScriptChunk]) -> Result<PushDropData, ScriptError> {
278    if chunks[0].data.is_none() || chunks[1].op != Op::OpCheckSig {
279        return Err(ScriptError::InvalidScript(
280            "PushDrop::decode(before): expected <pubkey> OP_CHECKSIG at start".into(),
281        ));
282    }
283    let locking_public_key = PublicKey::from_der_bytes(chunks[0].data.as_ref().unwrap())
284        .map_err(|e| ScriptError::InvalidScript(format!("PushDrop::decode: pubkey: {e}")))?;
285
286    let mut fields = Vec::new();
287    for i in 2..chunks.len() {
288        let next_is_drop = chunks
289            .get(i + 1)
290            .is_some_and(|next| next.op == Op::OpDrop || next.op == Op::Op2Drop);
291
292        if chunks[i].op == Op::OpDrop || chunks[i].op == Op::Op2Drop {
293            break;
294        }
295
296        // Reconstruct opcode-encoded fields rather than skipping chunks with no
297        // data payload — skipping them silently LOSES minimally-encoded fields.
298        fields.push(decode_field(&chunks[i]));
299
300        if next_is_drop {
301            break;
302        }
303    }
304
305    Ok(PushDropData {
306        locking_public_key,
307        fields,
308    })
309}
310
311/// Decode "after" layout: `<fields...> OP_2DROP... <pubkey> OP_CHECKSIG`
312fn decode_after(chunks: &[ScriptChunk]) -> Result<PushDropData, ScriptError> {
313    let last = &chunks[chunks.len() - 1];
314    if last.op != Op::OpCheckSig {
315        return Err(ScriptError::InvalidScript(
316            "PushDrop::decode(after): last opcode must be OP_CHECKSIG".into(),
317        ));
318    }
319    let pubkey_chunk = &chunks[chunks.len() - 2];
320    let pubkey_bytes = pubkey_chunk.data.as_ref().ok_or_else(|| {
321        ScriptError::InvalidScript(
322            "PushDrop::decode(after): expected pubkey before OP_CHECKSIG".into(),
323        )
324    })?;
325    let locking_public_key = PublicKey::from_der_bytes(pubkey_bytes)
326        .map_err(|e| ScriptError::InvalidScript(format!("PushDrop::decode: pubkey: {e}")))?;
327
328    // Walk backwards from before the pubkey, counting the DROP tail.
329    let mut drop_field_count = 0usize;
330    let mut pos = chunks.len() - 3;
331    loop {
332        let chunk = &chunks[pos];
333        if chunk.op == Op::Op2Drop {
334            drop_field_count += 2;
335        } else if chunk.op == Op::OpDrop {
336            drop_field_count += 1;
337        } else {
338            break;
339        }
340        if pos == 0 {
341            break;
342        }
343        pos -= 1;
344    }
345
346    if drop_field_count == 0 {
347        return Err(ScriptError::InvalidScript(
348            "PushDrop::decode(after): no OP_DROP/OP_2DROP found".into(),
349        ));
350    }
351    if drop_field_count > chunks.len() {
352        return Err(ScriptError::InvalidScript(
353            "PushDrop::decode(after): drop count exceeds script length".into(),
354        ));
355    }
356
357    // Reconstruct opcode-encoded fields. The previous code ERRORED on them
358    // ("expected data push"), making TS/Go-minted tokens with a minimally
359    // encoded field undecodable in this layout.
360    let fields = chunks[0..drop_field_count]
361        .iter()
362        .map(decode_field)
363        .collect();
364
365    Ok(PushDropData {
366        locking_public_key,
367        fields,
368    })
369}
370
371/// Byte-for-byte port of TS `createMinimallyEncodedScriptChunk` /
372/// Go `CreateMinimallyEncodedScriptChunk`.
373///
374/// The minimal forms are NOT cosmetic: both reference SDKs emit a bare opcode
375/// (no data payload) for `[]`, `[0]`, single bytes `1..=16`, and `[0x81]`. A
376/// port that push-encodes those instead produces a different script — and a
377/// decoder that only reads chunks carrying `data` silently loses the field.
378/// See [`decode_field`], the inverse.
379///
380/// Quirk preserved deliberately for parity: both references map `[]` AND `[0]`
381/// to `OP_0`, and decode `OP_0` back to `[0]` — so an empty field round-trips
382/// to `[0]`. Bug-for-bug on purpose; diverging would desync the wire.
383fn make_data_push(data: &[u8]) -> ScriptChunk {
384    if data.is_empty() {
385        return ScriptChunk::new_opcode(Op::Op0);
386    }
387    if data.len() == 1 {
388        let b = data[0];
389        if b == 0 {
390            return ScriptChunk::new_opcode(Op::Op0);
391        }
392        if (1..=16).contains(&b) {
393            // OP_1 ..= OP_16 == 0x51 ..= 0x60
394            return ScriptChunk::new_raw(0x50 + b, None);
395        }
396        if b == 0x81 {
397            return ScriptChunk::new_opcode(Op::Op1Negate);
398        }
399    }
400
401    let len = data.len();
402    if len < 0x4c {
403        ScriptChunk::new_raw(len as u8, Some(data.to_vec()))
404    } else if len < 256 {
405        ScriptChunk::new_raw(Op::OpPushData1.to_byte(), Some(data.to_vec()))
406    } else if len < 65536 {
407        ScriptChunk::new_raw(Op::OpPushData2.to_byte(), Some(data.to_vec()))
408    } else {
409        ScriptChunk::new_raw(Op::OpPushData4.to_byte(), Some(data.to_vec()))
410    }
411}
412
413/// Inverse of [`make_data_push`] — recover one field from a chunk,
414/// reconstructing the minimally-encoded opcode forms.
415///
416/// # Follows the GO SDK, not the TS SDK — they disagree, and TS is wrong
417///
418/// TS `PushDrop.decode` gates on `op >= 80 && op <= 95` (`0x50..=0x5f`), which
419/// EXCLUDES `OP_16` (`0x60`) — even though its own encoder emits `OP_16` for
420/// `[16]`. So @bsv/sdk cannot round-trip a `[16]` field: it decodes to empty.
421/// Verified against @bsv/sdk 2.0.13 — `lock([[16],[15]])` decodes to
422/// `["", "0f"]`. That is silent data loss in the TS SDK.
423///
424/// go-sdk v1.2.24 gates on `Op1-1 ..= Op16` (`0x50..=0x60`) and round-trips
425/// correctly. We match Go. The `0x50` low bound is shared by both references
426/// (it maps `0x50` to `[0]`); our encoder never emits `0x50`, so it only
427/// matters when decoding foreign scripts — and there we match.
428fn decode_field(chunk: &ScriptChunk) -> Vec<u8> {
429    if let Some(data) = &chunk.data {
430        if !data.is_empty() {
431            return data.clone();
432        }
433    }
434    match chunk.op_byte {
435        // 0x50 -> [0]; 0x51..=0x60 -> OP_1..=OP_16 -> [1..=16]
436        0x50..=0x60 => vec![chunk.op_byte - 0x50],
437        0x00 => vec![0],    // OP_0
438        0x4f => vec![0x81], // OP_1NEGATE
439        _ => Vec::new(),
440    }
441}
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446    use crate::primitives::private_key::PrivateKey;
447    use crate::wallet::proto_wallet::ProtoWallet;
448
449    fn wallet() -> ProtoWallet {
450        ProtoWallet::new(PrivateKey::from_bytes(&[0x55u8; 32]).unwrap())
451    }
452
453    fn protocol() -> Protocol {
454        Protocol {
455            security_level: 2,
456            protocol: "did revocation".to_string(),
457        }
458    }
459
460    fn cpty() -> Counterparty {
461        Counterparty {
462            counterparty_type: crate::wallet::types::CounterpartyType::Self_,
463            public_key: None,
464        }
465    }
466
467    #[tokio::test]
468    async fn lock_derives_the_pubkey_from_the_wallet_not_the_raw_key() {
469        let script = PushDrop::new(&wallet(), None)
470            .lock(
471                vec![b"hello".to_vec()],
472                protocol(),
473                "k",
474                cpty(),
475                false,
476                false,
477                LockPosition::Before,
478            )
479            .await
480            .unwrap();
481
482        let decoded = decode(&script).unwrap();
483        let raw = PrivateKey::from_bytes(&[0x55u8; 32])
484            .unwrap()
485            .to_public_key();
486
487        assert_ne!(
488            decoded.locking_public_key.to_der_hex(),
489            raw.to_der_hex(),
490            "the locking key must be a BRC-42 DERIVED child, never the raw key"
491        );
492        assert_eq!(decoded.fields, vec![b"hello".to_vec()]);
493    }
494
495    /// `include_signature` (the TS/Go default) appends the signature AS A FIELD,
496    /// so it changes both the field count and the OP_2DROP/OP_DROP tail.
497    #[tokio::test]
498    async fn include_signature_appends_the_signature_as_an_extra_field() {
499        let fields = vec![b"a".to_vec(), b"b".to_vec()];
500
501        let no_sig = PushDrop::new(&wallet(), None)
502            .lock(
503                fields.clone(),
504                protocol(),
505                "k",
506                cpty(),
507                false,
508                false,
509                LockPosition::Before,
510            )
511            .await
512            .unwrap();
513        let with_sig = PushDrop::new(&wallet(), None)
514            .lock(
515                fields.clone(),
516                protocol(),
517                "k",
518                cpty(),
519                false,
520                true,
521                LockPosition::Before,
522            )
523            .await
524            .unwrap();
525
526        let d_no = decode(&no_sig).unwrap();
527        let d_with = decode(&with_sig).unwrap();
528
529        assert_eq!(d_no.fields.len(), 2);
530        assert_eq!(d_with.fields.len(), 3, "the signature is an extra field");
531        assert_eq!(&d_with.fields[..2], &fields[..]);
532        assert!(
533            d_with.fields[2].starts_with(&[0x30]),
534            "the appended field is a DER signature"
535        );
536    }
537
538    /// Single bytes 1..=16 must use the minimal OP_1..OP_16 forms, and round-trip.
539    #[tokio::test]
540    async fn minimally_encoded_fields_round_trip() {
541        let fields = vec![
542            vec![0x01],
543            vec![0x10],
544            vec![0x81],
545            vec![0x00],
546            b"abc".to_vec(),
547        ];
548        let script = PushDrop::new(&wallet(), None)
549            .lock(
550                fields.clone(),
551                protocol(),
552                "k",
553                cpty(),
554                false,
555                false,
556                LockPosition::Before,
557            )
558            .await
559            .unwrap();
560
561        let hex = script.to_hex();
562        assert!(hex.contains("51"), "[1] must encode as OP_1");
563        assert!(hex.contains("60"), "[16] must encode as OP_16");
564        assert!(hex.contains("4f"), "[0x81] must encode as OP_1NEGATE");
565
566        let decoded = decode(&script).unwrap();
567        assert_eq!(decoded.fields, fields);
568    }
569
570    #[tokio::test]
571    async fn after_position_round_trips() {
572        let fields = vec![b"x".to_vec(), b"y".to_vec()];
573        let script = PushDrop::new(&wallet(), None)
574            .lock(
575                fields.clone(),
576                protocol(),
577                "k",
578                cpty(),
579                false,
580                false,
581                LockPosition::After,
582            )
583            .await
584            .unwrap();
585
586        let decoded = decode_with_position(&script, LockPosition::After).unwrap();
587        assert_eq!(decoded.fields, fields);
588    }
589
590    #[tokio::test]
591    async fn unlock_produces_a_der_signature_with_the_sighash_byte() {
592        let sig = PushDrop::new(&wallet(), None)
593            .unlock(
594                b"preimage",
595                protocol(),
596                "k",
597                cpty(),
598                PushDrop::<ProtoWallet>::default_sighash_type(),
599            )
600            .await
601            .unwrap();
602
603        let chunks = sig.chunks();
604        assert_eq!(chunks.len(), 1);
605        let data = chunks[0].data.as_ref().unwrap();
606        assert_eq!(data[0], 0x30, "DER sequence");
607        assert_eq!(
608            *data.last().unwrap(),
609            PushDrop::<ProtoWallet>::default_sighash_type()
610        );
611    }
612
613    #[test]
614    fn decode_non_pushdrop_errors() {
615        let script = LockingScript::from_hex("76a914").expect("parses as a script");
616        assert!(decode(&script).is_err(), "a P2PKH prefix is not a PushDrop");
617    }
618}