diff --git a/package.json b/package.json index 5128396fa..cda16923c 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,6 @@ "blech32": "^1.0.1", "bs58check": "^2.0.0", "create-hash": "^1.2.0", - "ecpair": "^2.1.0", "slip77": "^0.2.0", "typeforce": "^1.11.3", "varuint-bitcoin": "^1.1.2" @@ -78,6 +77,7 @@ "bn.js": "^4.11.8", "bs58": "^4.0.0", "dhttp": "^3.0.0", + "ecpair": "^2.1.0", "hoodwink": "^2.0.0", "minimaldata": "^1.0.2", "mocha": "^10.1.0", @@ -88,7 +88,7 @@ "randombytes": "^2.1.0", "regtest-client": "0.2.0", "rimraf": "^2.6.3", - "tiny-secp256k1": "^2.2.1", + "tiny-secp256k1": "^2.2.3", "ts-node": "^10.9.1", "tslint": "^6.1.3", "typescript": "^4.4.4" diff --git a/src/bip341.js b/src/bip341.js index 6255f4d2a..1335c5242 100644 --- a/src/bip341.js +++ b/src/bip341.js @@ -120,7 +120,7 @@ function taprootSignScriptStack(ecc) { return (internalPublicKey, leaf, treeRootHash, path) => { const { parity } = tweakPublicKey(internalPublicKey, treeRootHash, ecc); const parityBit = Buffer.of( - leaf.version || exports.LEAF_VERSION_TAPSCRIPT + parity, + (leaf.version || exports.LEAF_VERSION_TAPSCRIPT) + parity, ); const control = Buffer.concat([ parityBit, diff --git a/src/crypto.d.ts b/src/crypto.d.ts index d97f4c62f..4c76964f9 100644 --- a/src/crypto.d.ts +++ b/src/crypto.d.ts @@ -7,4 +7,12 @@ export declare function hash256(buffer: Buffer): Buffer; declare const TAGS: readonly ["BIP0340/challenge", "BIP0340/aux", "BIP0340/nonce", "TapLeaf", "TapLeaf/elements", "TapBranch/elements", "TapSighash", "TapSighash/elements", "TapTweak", "TapTweak/elements", "KeyAgg list", "KeyAgg coefficient"]; export declare type TaggedHashPrefix = typeof TAGS[number]; export declare function taggedHash(prefix: TaggedHashPrefix, data: Buffer): Buffer; +/** + * Serialize outpoint as txid | vout, sort them and sha256 the concatenated result + * @param parameters list of outpoints (txid, vout) + */ +export declare function hashOutpoints(parameters: { + txid: string; + vout: number; +}[]): Buffer; export {}; diff --git a/src/crypto.js b/src/crypto.js index 1d833e28f..339d06683 100644 --- a/src/crypto.js +++ b/src/crypto.js @@ -5,7 +5,8 @@ var __importDefault = return mod && mod.__esModule ? mod : { default: mod }; }; Object.defineProperty(exports, '__esModule', { value: true }); -exports.taggedHash = +exports.hashOutpoints = + exports.taggedHash = exports.hash256 = exports.hash160 = exports.sha256 = @@ -62,3 +63,27 @@ function taggedHash(prefix, data) { return sha256(Buffer.concat([TAGGED_HASH_PREFIXES[prefix], data])); } exports.taggedHash = taggedHash; +/** + * Serialize outpoint as txid | vout, sort them and sha256 the concatenated result + * @param parameters list of outpoints (txid, vout) + */ +function hashOutpoints(parameters) { + let bufferConcat = Buffer.alloc(0); + const outpoints = []; + for (const parameter of parameters) { + const voutBuffer = Buffer.allocUnsafe(4); + voutBuffer.writeUint32BE(parameter.vout, 0); + outpoints.push( + Buffer.concat([ + Buffer.from(parameter.txid, 'hex').reverse(), + voutBuffer.reverse(), + ]), + ); + } + outpoints.sort(Buffer.compare); + for (const outpoint of outpoints) { + bufferConcat = Buffer.concat([bufferConcat, outpoint]); + } + return sha256(bufferConcat); +} +exports.hashOutpoints = hashOutpoints; diff --git a/src/index.d.ts b/src/index.d.ts index 2692730a0..52c1eb706 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -1,12 +1,12 @@ -import * as address from './address'; -import * as crypto from './crypto'; -import * as networks from './networks'; -import * as payments from './payments'; -import * as script from './script'; -import * as issuance from './issuance'; -import * as bip341 from './bip341'; -import * as confidential from './confidential'; -export { address, confidential, crypto, networks, payments, script, issuance, bip341, }; +export * as address from './address'; +export * as crypto from './crypto'; +export * as networks from './networks'; +export * as payments from './payments'; +export * as script from './script'; +export * as issuance from './issuance'; +export * as bip341 from './bip341'; +export * as confidential from './confidential'; +export * as silentpayment from './silentpayment'; export { OPS as opcodes } from './ops'; export { Input as TxInput, Output as TxOutput, Transaction, } from './transaction'; export * from './asset'; diff --git a/src/index.js b/src/index.js index e4a9c75e3..cc99d419f 100644 --- a/src/index.js +++ b/src/index.js @@ -53,31 +53,25 @@ var __exportStar = Object.defineProperty(exports, '__esModule', { value: true }); exports.Transaction = exports.opcodes = + exports.silentpayment = + exports.confidential = exports.bip341 = exports.issuance = exports.script = exports.payments = exports.networks = exports.crypto = - exports.confidential = exports.address = void 0; -const address = __importStar(require('./address')); -exports.address = address; -const crypto = __importStar(require('./crypto')); -exports.crypto = crypto; -const networks = __importStar(require('./networks')); -exports.networks = networks; -const payments = __importStar(require('./payments')); -exports.payments = payments; -const script = __importStar(require('./script')); -exports.script = script; -const issuance = __importStar(require('./issuance')); -exports.issuance = issuance; -const bip341 = __importStar(require('./bip341')); -exports.bip341 = bip341; -const confidential = __importStar(require('./confidential')); -exports.confidential = confidential; +exports.address = __importStar(require('./address')); +exports.crypto = __importStar(require('./crypto')); +exports.networks = __importStar(require('./networks')); +exports.payments = __importStar(require('./payments')); +exports.script = __importStar(require('./script')); +exports.issuance = __importStar(require('./issuance')); +exports.bip341 = __importStar(require('./bip341')); +exports.confidential = __importStar(require('./confidential')); +exports.silentpayment = __importStar(require('./silentpayment')); var ops_1 = require('./ops'); Object.defineProperty(exports, 'opcodes', { enumerable: true, diff --git a/src/silentpayment.d.ts b/src/silentpayment.d.ts new file mode 100644 index 000000000..45334dd86 --- /dev/null +++ b/src/silentpayment.d.ts @@ -0,0 +1,27 @@ +/// +export declare type Outpoint = { + txid: string; + vout: number; +}; +export interface TinySecp256k1Interface { + privateMultiply: (key: Uint8Array, tweak: Uint8Array) => Uint8Array; + pointMultiply: (point: Uint8Array, tweak: Uint8Array) => Uint8Array | null; + pointAdd: (point1: Uint8Array, point2: Uint8Array) => Uint8Array | null; + pointFromScalar: (key: Uint8Array) => Uint8Array | null; + privateAdd: (key: Uint8Array, tweak: Uint8Array) => Uint8Array | null; + privateNegate: (key: Uint8Array) => Uint8Array; +} +export interface SilentPayment { + scriptPubKey(inputs: Outpoint[], inputPrivateKey: Buffer, silentPaymentAddress: string, index?: number): Buffer; + ecdhSharedSecret(secret: Buffer, pubkey: Buffer, seckey: Buffer): Buffer; + publicKey(spendPubKey: Buffer, index: number, ecdhSharedSecret: Buffer): Buffer; + secretKey(spendPrivKey: Buffer, index: number, ecdhSharedSecret: Buffer): Buffer; +} +export declare class SilentPaymentAddress { + readonly spendPublicKey: Buffer; + readonly scanPublicKey: Buffer; + constructor(spendPublicKey: Buffer, scanPublicKey: Buffer); + static decode(str: string): SilentPaymentAddress; + encode(): string; +} +export declare function SPFactory(ecc: TinySecp256k1Interface): SilentPayment; diff --git a/src/silentpayment.js b/src/silentpayment.js new file mode 100644 index 000000000..44284f3c4 --- /dev/null +++ b/src/silentpayment.js @@ -0,0 +1,125 @@ +'use strict'; +Object.defineProperty(exports, '__esModule', { value: true }); +exports.SPFactory = exports.SilentPaymentAddress = void 0; +const bech32_1 = require('bech32'); +const crypto_1 = require('./crypto'); +class SilentPaymentAddress { + constructor(spendPublicKey, scanPublicKey) { + this.spendPublicKey = spendPublicKey; + this.scanPublicKey = scanPublicKey; + if (spendPublicKey.length !== 33 || scanPublicKey.length !== 33) { + throw new Error( + 'Invalid public key length, expected 33 bytes public key', + ); + } + } + static decode(str) { + const result = bech32_1.bech32m.decode(str, 118); + const version = result.words.shift(); + if (version !== 0) { + throw new Error('Unexpected version of silent payment code'); + } + const data = bech32_1.bech32m.fromWords(result.words); + const scanPubKey = Buffer.from(data.slice(0, 33)); + const spendPubKey = Buffer.from(data.slice(33)); + return new SilentPaymentAddress(spendPubKey, scanPubKey); + } + encode() { + const data = Buffer.concat([this.scanPublicKey, this.spendPublicKey]); + const words = bech32_1.bech32m.toWords(data); + words.unshift(0); + return bech32_1.bech32m.encode('sp', words, 118); + } +} +exports.SilentPaymentAddress = SilentPaymentAddress; +// inject ecc dependency, returns a SilentPayment interface +function SPFactory(ecc) { + return new SilentPaymentImpl(ecc); +} +exports.SPFactory = SPFactory; +const SEGWIT_V1_SCRIPT_PREFIX = Buffer.from([0x51, 0x20]); +const G = Buffer.from( + '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', + 'hex', +); +class SilentPaymentImpl { + constructor(ecc) { + this.ecc = ecc; + } + /** + * Compute scriptPubKey used to send funds to a silent payment address + * @param inputs list of ALL outpoints of the transaction sending to the silent payment address + * @param inputPrivateKey private key owning the spent outpoint. Sum of all private keys if multiple inputs + * @param silentPaymentAddress target of the scriptPubKey + * @param index index of the silent payment address. Prevent address reuse if multiple silent addresses are in the same transaction. + * @returns the output scriptPubKey belonging to the silent payment address + */ + scriptPubKey(inputs, inputPrivateKey, silentPaymentAddress, index = 0) { + const inputsHash = (0, crypto_1.hashOutpoints)(inputs); + const addr = SilentPaymentAddress.decode(silentPaymentAddress); + const sharedSecret = this.ecdhSharedSecret( + inputsHash, + addr.scanPublicKey, + inputPrivateKey, + ); + const outputPublicKey = this.publicKey( + addr.spendPublicKey, + index, + sharedSecret, + ); + return Buffer.concat([SEGWIT_V1_SCRIPT_PREFIX, outputPublicKey.slice(1)]); + } + /** + * ECDH shared secret used to share outpoints hash of the transactions. + * @param secret hash of the outpoints of the transaction sending to the silent payment address + */ + ecdhSharedSecret(secret, pubkey, seckey) { + const ecdhSharedSecretStep = Buffer.from( + this.ecc.privateMultiply(secret, seckey), + ); + const ecdhSharedSecret = this.ecc.pointMultiply( + pubkey, + ecdhSharedSecretStep, + ); + if (!ecdhSharedSecret) { + throw new Error('Invalid ecdh shared secret'); + } + return Buffer.from(ecdhSharedSecret); + } + /** + * Compute the output public key of a silent payment address. + * @param spendPubKey spend public key of the silent payment address + * @param index index of the silent payment address. + * @param ecdhSharedSecret ecdh shared secret identifying the transaction. + * @returns 33 bytes public key + */ + publicKey(spendPubKey, index, ecdhSharedSecret) { + const hash = hashSharedSecret(ecdhSharedSecret, index); + const asPoint = this.ecc.pointMultiply(G, hash); + if (!asPoint) throw new Error('Invalid Tn'); + const pubkey = this.ecc.pointAdd(asPoint, spendPubKey); + if (!pubkey) throw new Error('Invalid pubkey'); + return Buffer.from(pubkey); + } + /** + * Compute the secret key locking the funds sent to a silent payment address. + * @param spendPrivKey spend private key of the silent payment address + * @param index index of the silent payment address. + * @param ecdhSharedSecret ecdh shared secret identifying the transaction + * @returns 32 bytes key + */ + secretKey(spendPrivKey, index, ecdhSharedSecret) { + const hash = hashSharedSecret(ecdhSharedSecret, index); + let privkey = this.ecc.privateAdd(spendPrivKey, hash); + if (!privkey) throw new Error('Invalid privkey'); + if (this.ecc.pointFromScalar(privkey)?.[0] === 0x03) { + privkey = this.ecc.privateNegate(privkey); + } + return Buffer.from(privkey); + } +} +function hashSharedSecret(secret, index) { + const serializedIndex = Buffer.allocUnsafe(4); + serializedIndex.writeUint32BE(index, 0); + return (0, crypto_1.sha256)(Buffer.concat([secret, serializedIndex])); +} diff --git a/test/integration/_regtest.ts b/test/integration/_regtest.ts index 6a0779187..cc57124db 100644 --- a/test/integration/_regtest.ts +++ b/test/integration/_regtest.ts @@ -1,4 +1,15 @@ import axios from 'axios'; +import * as ecc from 'tiny-secp256k1'; +import { + BIP174SigningData, + Extractor, + Finalizer, + Pset, + Signer, + Transaction, + script, +} from '../../ts_src'; +import { ECDSAVerifier, SchnorrVerifier } from '../../ts_src/psetv2/pset'; const APIURL = process.env.APIURL || 'http://localhost:3001'; export const TESTNET_APIURL = 'https://blockstream.info/liquidtestnet/api'; @@ -101,3 +112,33 @@ export async function broadcast( function sleep(ms: number): Promise { return new Promise((res: any): any => setTimeout(res, ms)); } + +export function signTransaction( + pset: Pset, + signers: any[], + sighashType: number, + ecclib: ECDSAVerifier & SchnorrVerifier = ecc, +): Transaction { + const signer = new Signer(pset); + + signers.forEach((keyPairs, i) => { + const preimage = pset.getInputPreimage(i, sighashType); + keyPairs.forEach((kp: any) => { + const partialSig: BIP174SigningData = { + partialSig: { + pubkey: kp.publicKey, + signature: script.signature.encode(kp.sign(preimage), sighashType), + }, + }; + signer.addSignature(i, partialSig, Pset.ECDSASigValidator(ecclib)); + }); + }); + + if (!pset.validateAllSignatures(Pset.ECDSASigValidator(ecclib))) { + throw new Error('Failed to sign pset'); + } + + const finalizer = new Finalizer(pset); + finalizer.finalize(); + return Extractor.extract(pset); +} diff --git a/test/integration/psetv2.spec.ts b/test/integration/psetv2.spec.ts index 5955b370b..9faf8bc41 100644 --- a/test/integration/psetv2.spec.ts +++ b/test/integration/psetv2.spec.ts @@ -27,7 +27,6 @@ import { BIP371SigningData } from '../../ts_src/psetv2'; import { toXOnly } from '../../ts_src/psetv2/bip371'; import secp256k1 from '@vulpemventures/secp256k1-zkp'; import { issuanceEntropyFromInput } from '../../ts_src/issuance'; -import { ECDSAVerifier, SchnorrVerifier } from '../../ts_src/psetv2/pset'; const OPS = bscript.OPS; const { BIP341Factory } = bip341; @@ -72,7 +71,7 @@ describe('liquidjs-lib (transactions with psetv2)', () => { updater.addInputs(inputs); updater.addOutputs(outputs); - const rawTx = signTransaction( + const rawTx = regtestUtils.signTransaction( pset, [alice.keys], Transaction.SIGHASH_ALL, @@ -134,7 +133,11 @@ describe('liquidjs-lib (transactions with psetv2)', () => { zkpGenerator, ); blinder.blindLast({ outputBlindingArgs }); - const rawTx = signTransaction(pset, [alice.keys], Transaction.SIGHASH_ALL); + const rawTx = regtestUtils.signTransaction( + pset, + [alice.keys], + Transaction.SIGHASH_ALL, + ); await regtestUtils.broadcast(rawTx.toHex()); }); @@ -189,7 +192,11 @@ describe('liquidjs-lib (transactions with psetv2)', () => { zkpGenerator, ); blinder.blindLast({ outputBlindingArgs }); - const rawTx = signTransaction(pset, [alice.keys], Transaction.SIGHASH_ALL); + const rawTx = regtestUtils.signTransaction( + pset, + [alice.keys], + Transaction.SIGHASH_ALL, + ); await regtestUtils.broadcast(rawTx.toHex()); }); @@ -255,7 +262,7 @@ describe('liquidjs-lib (transactions with psetv2)', () => { zkpGenerator, ); blinder.blindLast({ outputBlindingArgs }); - const rawTx = signTransaction( + const rawTx = regtestUtils.signTransaction( pset, [alice.keys, unconfidentialAlice.keys], Transaction.SIGHASH_ALL, @@ -314,7 +321,11 @@ describe('liquidjs-lib (transactions with psetv2)', () => { zkpGenerator, ); blinder.blindLast({ outputBlindingArgs }); - const rawTx = signTransaction(pset, [alice.keys], Transaction.SIGHASH_ALL); + const rawTx = regtestUtils.signTransaction( + pset, + [alice.keys], + Transaction.SIGHASH_ALL, + ); await regtestUtils.broadcast(rawTx.toHex()); }); @@ -370,7 +381,11 @@ describe('liquidjs-lib (transactions with psetv2)', () => { zkpGenerator, ); blinder.blindLast({ outputBlindingArgs }); - const rawTx = signTransaction(pset, [alice.keys], Transaction.SIGHASH_ALL); + const rawTx = regtestUtils.signTransaction( + pset, + [alice.keys], + Transaction.SIGHASH_ALL, + ); await regtestUtils.broadcast(rawTx.toHex()); }); @@ -426,7 +441,11 @@ describe('liquidjs-lib (transactions with psetv2)', () => { zkpGenerator, ); blinder.blindLast({ outputBlindingArgs }); - const rawTx = signTransaction(pset, [alice.keys], Transaction.SIGHASH_ALL); + const rawTx = regtestUtils.signTransaction( + pset, + [alice.keys], + Transaction.SIGHASH_ALL, + ); await regtestUtils.broadcast(rawTx.toHex()); }); @@ -484,7 +503,7 @@ describe('liquidjs-lib (transactions with psetv2)', () => { zkpGenerator, ); blinder.blindLast({ issuanceBlindingArgs, outputBlindingArgs }); - const issuanceTx = signTransaction( + const issuanceTx = regtestUtils.signTransaction( pset, [alice.keys], Transaction.SIGHASH_ALL, @@ -559,7 +578,7 @@ describe('liquidjs-lib (transactions with psetv2)', () => { outputBlindingArgs: reissuanceOutputBlindingArgs, }); - const reissuanceTx = signTransaction( + const reissuanceTx = regtestUtils.signTransaction( reissuancePset, [alice.keys, alice.keys], Transaction.SIGHASH_ALL, @@ -622,7 +641,7 @@ describe('liquidjs-lib (transactions with psetv2)', () => { zkpGenerator, ); blinder.blindLast({ issuanceBlindingArgs, outputBlindingArgs }); - const issuanceTx = signTransaction( + const issuanceTx = regtestUtils.signTransaction( pset, [alice.keys], Transaction.SIGHASH_ALL, @@ -697,7 +716,7 @@ describe('liquidjs-lib (transactions with psetv2)', () => { outputBlindingArgs: reissuanceOutputBlindingArgs, }); - const reissuanceTx = signTransaction( + const reissuanceTx = regtestUtils.signTransaction( reissuancePset, [alice.keys, alice.keys], Transaction.SIGHASH_ALL, @@ -757,7 +776,7 @@ describe('liquidjs-lib (transactions with psetv2)', () => { zkpGenerator, ); blinder.blindLast({ outputBlindingArgs }); - const issuanceTx = signTransaction( + const issuanceTx = regtestUtils.signTransaction( pset, [alice.keys], Transaction.SIGHASH_ALL, @@ -826,7 +845,7 @@ describe('liquidjs-lib (transactions with psetv2)', () => { outputBlindingArgs: reissuanceOutputBlindingArgs, }); - const reissuanceTx = signTransaction( + const reissuanceTx = regtestUtils.signTransaction( reissuancePset, [alice.keys, alice.keys], Transaction.SIGHASH_ALL, @@ -889,7 +908,11 @@ describe('liquidjs-lib (transactions with psetv2)', () => { zkpGenerator, ); blinder.blindLast({ issuanceBlindingArgs, outputBlindingArgs }); - const rawTx = signTransaction(pset, [alice.keys], Transaction.SIGHASH_ALL); + const rawTx = regtestUtils.signTransaction( + pset, + [alice.keys], + Transaction.SIGHASH_ALL, + ); await regtestUtils.broadcast(rawTx.toHex()); }); @@ -986,7 +1009,7 @@ describe('liquidjs-lib (transactions with psetv2)', () => { zkpGenerator, ); blinder.blindLast({ outputBlindingArgs }); - const rawTx = signTransaction( + const rawTx = regtestUtils.signTransaction( pset, [alice.keys, bob.keys], Transaction.SIGHASH_ALL, @@ -1289,7 +1312,11 @@ describe('liquidjs-lib (transactions with psetv2)', () => { // need 3 signers const signersKeys = [...p2sh.keys].slice(0, 3); - const tx = signTransaction(pset, [signersKeys], Transaction.SIGHASH_ALL); + const tx = regtestUtils.signTransaction( + pset, + [signersKeys], + Transaction.SIGHASH_ALL, + ); await regtestUtils.broadcast(tx.toHex()); }, ); @@ -1348,7 +1375,7 @@ describe('liquidjs-lib (transactions with psetv2)', () => { zkpGenerator, ); blinder.blindLast({ outputBlindingArgs }); - const rawTx = signTransaction( + const rawTx = regtestUtils.signTransaction( pset, [alice.keys, bob.keys], Transaction.SIGHASH_ALL, @@ -1357,36 +1384,6 @@ describe('liquidjs-lib (transactions with psetv2)', () => { }); }); -function signTransaction( - pset: Pset, - signers: any[], - sighashType: number, - ecclib: ECDSAVerifier & SchnorrVerifier = ecc, -): Transaction { - const signer = new PsetSigner(pset); - - signers.forEach((keyPairs, i) => { - const preimage = pset.getInputPreimage(i, sighashType); - keyPairs.forEach((kp: any) => { - const partialSig: BIP174SigningData = { - partialSig: { - pubkey: kp.publicKey, - signature: bscript.signature.encode(kp.sign(preimage), sighashType), - }, - }; - signer.addSignature(i, partialSig, Pset.ECDSASigValidator(ecclib)); - }); - }); - - if (!pset.validateAllSignatures(Pset.ECDSASigValidator(ecclib))) { - throw new Error('Failed to sign pset'); - } - - const finalizer = new PsetFinalizer(pset); - finalizer.finalize(); - return PsetExtractor.extract(pset); -} - const serializeSchnnorrSig = (sig: Buffer, hashtype: number) => Buffer.concat([ sig, diff --git a/test/integration/silentpayment.spec.ts b/test/integration/silentpayment.spec.ts new file mode 100644 index 000000000..a81aa86a5 --- /dev/null +++ b/test/integration/silentpayment.spec.ts @@ -0,0 +1,192 @@ +import secp256k1 from '@vulpemventures/secp256k1-zkp'; +import * as tinyecc from 'tiny-secp256k1'; +import * as assert from 'assert'; +import { + BIP371SigningData, + Creator, + CreatorInput, + CreatorOutput, + Extractor, + Finalizer, + Pset, + Signer, + Transaction, + Updater, + bip341, + networks, + silentpayment, +} from '../../ts_src'; +import { createPayment, getInputData } from './utils'; +import { broadcast, signTransaction } from './_regtest'; +import { ECPair } from '../ecc'; +import { hashOutpoints } from '../../ts_src/crypto'; + +describe('Silent Payments', () => { + let ecc: silentpayment.TinySecp256k1Interface & + bip341.BIP341Secp256k1Interface; + let sp: silentpayment.SilentPayment; + + before(async () => { + const { ecc: stepEcc } = await secp256k1(); + ecc = { + ...stepEcc, + privateMultiply: stepEcc.privateMul, + pointAdd: tinyecc.pointAdd, + pointMultiply: (p: Uint8Array, tweak: Uint8Array) => + tinyecc.pointMultiply(p, tweak), + }; + sp = silentpayment.SPFactory(ecc); + }); + + it('should send payment to silent address', async () => { + // create and faucet an alice wallet + // bob will create a silent address, alice will send some L-BTC to it + const alice = createPayment('p2wpkh', undefined, undefined, false); + const aliceInputData = await getInputData(alice.payment, true, 'noredeem'); + + const bobKeyPairSpend = ECPair.makeRandom(); // sec in cold storage, pub public + const bobKeyPairScan = ECPair.makeRandom(); // sec & pub in hot storage, pub public + + // bob creates a silent address and shares it with alice + const bob = new silentpayment.SilentPaymentAddress( + bobKeyPairSpend.publicKey, + bobKeyPairScan.publicKey, + ).encode(); + + // alice adds the input + const inputs = [aliceInputData].map(({ hash, index }) => { + const txid: string = Buffer.from(hash).reverse().toString('hex'); + return new CreatorInput(txid, index); + }); + + const pset = Creator.newPset({ inputs }); + const updater = new Updater(pset); + updater.addInWitnessUtxo(0, aliceInputData.witnessUtxo); + updater.addInUtxoRangeProof(0, aliceInputData.witnessUtxo.rangeProof); + updater.addInSighashType(0, Transaction.SIGHASH_ALL); + + const sendAmount = 1000; + const fee = 400; + const change = 1_0000_0000 - sendAmount - fee; + + const script = sp.scriptPubKey( + inputs.map((i) => ({ txid: i.txid, vout: i.txIndex })), + alice.keys[0].privateKey!, + bob, + ); + + updater.addOutputs([ + { + amount: sendAmount, + asset: networks.regtest.assetHash, + script, + }, + ]); + + // add change & fee outputs + updater.addOutputs([ + { + amount: change, + asset: networks.regtest.assetHash, + script: alice.payment.output, + }, + { + amount: fee, + asset: networks.regtest.assetHash, + }, + ]); + + // alice signs the transaction + const signed = signTransaction( + updater.pset, + [alice.keys], + Transaction.SIGHASH_ALL, + ); + const tx = signed.toHex(); + await broadcast(tx); + + // check if bob can spend the output (key spend using private key) + const outputToSpend = signed.outs[0]; + + const bobInput = new CreatorInput(signed.getId(), 0); + + const bobOutput = new CreatorOutput( + networks.regtest.assetHash, + 600, + alice.payment.output, + ); + + const feeOutput = new CreatorOutput(networks.regtest.assetHash, fee); + + const bobPset = Creator.newPset({ + inputs: [bobInput], + outputs: [bobOutput, feeOutput], + }); + + const bobUpdater = new Updater(bobPset); + bobUpdater.addInWitnessUtxo(0, outputToSpend); + bobUpdater.addInSighashType(0, Transaction.SIGHASH_DEFAULT); + + const outpoints = inputs.map((i) => ({ txid: i.txid, vout: i.txIndex })); + const inputsHash = hashOutpoints(outpoints); + + const sharedSecret = sp.ecdhSharedSecret( + inputsHash, + alice.keys[0].publicKey!, + bobKeyPairScan.privateKey!, + ); + + const outputPublicKey = sp.publicKey( + bobKeyPairSpend.publicKey!, + 0, + sharedSecret, + ); + + const isBob = outputPublicKey + .subarray(1) + .equals(outputToSpend.script.subarray(2)); + + assert.strictEqual( + isBob, + true, + `outputPublicKey ${outputPublicKey.toString( + 'hex', + )} is not equal to outputToSpend.script ${outputToSpend.script + .subarray(2) + .toString('hex')}}`, + ); + + // then bob can use its private key (spend one) to recompute the signing key and spend the ouput + const privKey = sp.secretKey(bobKeyPairSpend.privateKey!, 0, sharedSecret); + + const preimage = bobPset.getInputPreimage( + 0, + Transaction.SIGHASH_DEFAULT, + networks.regtest.genesisBlockHash, + ); + + const signature = Buffer.from( + ecc.signSchnorr(preimage, privKey, Buffer.alloc(32)), + ); + const signer = new Signer(bobPset); + + const partialSig: BIP371SigningData = { + tapKeySig: serializeSchnnorrSig(signature, Transaction.SIGHASH_DEFAULT), + genesisBlockHash: networks.regtest.genesisBlockHash, + }; + signer.addSignature(0, partialSig, Pset.SchnorrSigValidator(ecc)); + + const finalizer = new Finalizer(bobPset); + finalizer.finalize(); + const bobTx = Extractor.extract(bobPset); + const hex = bobTx.toHex(); + + await broadcast(hex); + }); +}); + +const serializeSchnnorrSig = (sig: Buffer, hashtype: number) => + Buffer.concat([ + sig, + hashtype !== 0x00 ? Buffer.of(hashtype) : Buffer.alloc(0), + ]); diff --git a/ts_src/crypto.ts b/ts_src/crypto.ts index 28e223640..ef6cb753e 100644 --- a/ts_src/crypto.ts +++ b/ts_src/crypto.ts @@ -50,3 +50,32 @@ const TAGGED_HASH_PREFIXES = Object.fromEntries( export function taggedHash(prefix: TaggedHashPrefix, data: Buffer): Buffer { return sha256(Buffer.concat([TAGGED_HASH_PREFIXES[prefix], data])); } + +/** + * Serialize outpoint as txid | vout, sort them and sha256 the concatenated result + * @param parameters list of outpoints (txid, vout) + */ +export function hashOutpoints( + parameters: { txid: string; vout: number }[], +): Buffer { + let bufferConcat = Buffer.alloc(0); + const outpoints: Array = []; + for (const parameter of parameters) { + const voutBuffer = Buffer.allocUnsafe(4); + voutBuffer.writeUint32BE(parameter.vout, 0); + + outpoints.push( + Buffer.concat([ + Buffer.from(parameter.txid, 'hex').reverse(), + voutBuffer.reverse(), + ]), + ); + } + + outpoints.sort(Buffer.compare); + + for (const outpoint of outpoints) { + bufferConcat = Buffer.concat([bufferConcat, outpoint]); + } + return sha256(bufferConcat); +} diff --git a/ts_src/index.ts b/ts_src/index.ts index 9ef6bdc3f..ae87f2d14 100644 --- a/ts_src/index.ts +++ b/ts_src/index.ts @@ -1,21 +1,12 @@ -import * as address from './address'; -import * as crypto from './crypto'; -import * as networks from './networks'; -import * as payments from './payments'; -import * as script from './script'; -import * as issuance from './issuance'; -import * as bip341 from './bip341'; -import * as confidential from './confidential'; -export { - address, - confidential, - crypto, - networks, - payments, - script, - issuance, - bip341, -}; +export * as address from './address'; +export * as crypto from './crypto'; +export * as networks from './networks'; +export * as payments from './payments'; +export * as script from './script'; +export * as issuance from './issuance'; +export * as bip341 from './bip341'; +export * as confidential from './confidential'; +export * as silentpayment from './silentpayment'; export { OPS as opcodes } from './ops'; export { Input as TxInput, diff --git a/ts_src/psetv2/pset.ts b/ts_src/psetv2/pset.ts index da859ee2b..4565c8346 100644 --- a/ts_src/psetv2/pset.ts +++ b/ts_src/psetv2/pset.ts @@ -436,7 +436,7 @@ export class Pset { prevoutScripts, prevoutAssetsValues, sighashType, - genesisBlockHash!, + genesisBlockHash, leafHash, ); } diff --git a/ts_src/psetv2/updater.ts b/ts_src/psetv2/updater.ts index 989f2baaf..88fec407c 100644 --- a/ts_src/psetv2/updater.ts +++ b/ts_src/psetv2/updater.ts @@ -560,9 +560,10 @@ export class Updater { } const tweakedKey = input.getUtxo()!.script.slice(2); + const sighash = pset.getInputPreimage( inIndex, - input.sighashType!, + input.sighashType, genesisBlockHash, ); if (!validator(tweakedKey, sighash, sig)) { diff --git a/ts_src/silentpayment.ts b/ts_src/silentpayment.ts new file mode 100644 index 000000000..2b8bbe6d7 --- /dev/null +++ b/ts_src/silentpayment.ts @@ -0,0 +1,185 @@ +import { bech32m } from 'bech32'; +import { hashOutpoints, sha256 } from './crypto'; + +export type Outpoint = { + txid: string; + vout: number; +}; + +export interface TinySecp256k1Interface { + privateMultiply: (key: Uint8Array, tweak: Uint8Array) => Uint8Array; + pointMultiply: (point: Uint8Array, tweak: Uint8Array) => Uint8Array | null; + pointAdd: (point1: Uint8Array, point2: Uint8Array) => Uint8Array | null; + pointFromScalar: (key: Uint8Array) => Uint8Array | null; + privateAdd: (key: Uint8Array, tweak: Uint8Array) => Uint8Array | null; + privateNegate: (key: Uint8Array) => Uint8Array; +} + +export interface SilentPayment { + scriptPubKey( + inputs: Outpoint[], + inputPrivateKey: Buffer, + silentPaymentAddress: string, + index?: number, + ): Buffer; + ecdhSharedSecret(secret: Buffer, pubkey: Buffer, seckey: Buffer): Buffer; + publicKey( + spendPubKey: Buffer, + index: number, + ecdhSharedSecret: Buffer, + ): Buffer; + secretKey( + spendPrivKey: Buffer, + index: number, + ecdhSharedSecret: Buffer, + ): Buffer; +} + +export class SilentPaymentAddress { + constructor(readonly spendPublicKey: Buffer, readonly scanPublicKey: Buffer) { + if (spendPublicKey.length !== 33 || scanPublicKey.length !== 33) { + throw new Error( + 'Invalid public key length, expected 33 bytes public key', + ); + } + } + + static decode(str: string): SilentPaymentAddress { + const result = bech32m.decode(str, 118); + const version = result.words.shift(); + if (version !== 0) { + throw new Error('Unexpected version of silent payment code'); + } + const data = bech32m.fromWords(result.words); + const scanPubKey = Buffer.from(data.slice(0, 33)); + const spendPubKey = Buffer.from(data.slice(33)); + return new SilentPaymentAddress(spendPubKey, scanPubKey); + } + + encode(): string { + const data = Buffer.concat([this.scanPublicKey, this.spendPublicKey]); + + const words = bech32m.toWords(data); + words.unshift(0); + return bech32m.encode('sp', words, 118); + } +} + +// inject ecc dependency, returns a SilentPayment interface +export function SPFactory(ecc: TinySecp256k1Interface): SilentPayment { + return new SilentPaymentImpl(ecc); +} + +const SEGWIT_V1_SCRIPT_PREFIX = Buffer.from([0x51, 0x20]); + +const G = Buffer.from( + '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', + 'hex', +); + +class SilentPaymentImpl implements SilentPayment { + constructor(private ecc: TinySecp256k1Interface) {} + + /** + * Compute scriptPubKey used to send funds to a silent payment address + * @param inputs list of ALL outpoints of the transaction sending to the silent payment address + * @param inputPrivateKey private key owning the spent outpoint. Sum of all private keys if multiple inputs + * @param silentPaymentAddress target of the scriptPubKey + * @param index index of the silent payment address. Prevent address reuse if multiple silent addresses are in the same transaction. + * @returns the output scriptPubKey belonging to the silent payment address + */ + scriptPubKey( + inputs: Outpoint[], + inputPrivateKey: Buffer, + silentPaymentAddress: string, + index = 0, + ): Buffer { + const inputsHash = hashOutpoints(inputs); + const addr = SilentPaymentAddress.decode(silentPaymentAddress); + + const sharedSecret = this.ecdhSharedSecret( + inputsHash, + addr.scanPublicKey, + inputPrivateKey, + ); + + const outputPublicKey = this.publicKey( + addr.spendPublicKey, + index, + sharedSecret, + ); + + return Buffer.concat([SEGWIT_V1_SCRIPT_PREFIX, outputPublicKey.slice(1)]); + } + + /** + * ECDH shared secret used to share outpoints hash of the transactions. + * @param secret hash of the outpoints of the transaction sending to the silent payment address + */ + ecdhSharedSecret(secret: Buffer, pubkey: Buffer, seckey: Buffer): Buffer { + const ecdhSharedSecretStep = Buffer.from( + this.ecc.privateMultiply(secret, seckey), + ); + const ecdhSharedSecret = this.ecc.pointMultiply( + pubkey, + ecdhSharedSecretStep, + ); + + if (!ecdhSharedSecret) { + throw new Error('Invalid ecdh shared secret'); + } + + return Buffer.from(ecdhSharedSecret); + } + + /** + * Compute the output public key of a silent payment address. + * @param spendPubKey spend public key of the silent payment address + * @param index index of the silent payment address. + * @param ecdhSharedSecret ecdh shared secret identifying the transaction. + * @returns 33 bytes public key + */ + publicKey( + spendPubKey: Buffer, + index: number, + ecdhSharedSecret: Buffer, + ): Buffer { + const hash = hashSharedSecret(ecdhSharedSecret, index); + const asPoint = this.ecc.pointMultiply(G, hash); + if (!asPoint) throw new Error('Invalid Tn'); + + const pubkey = this.ecc.pointAdd(asPoint, spendPubKey); + if (!pubkey) throw new Error('Invalid pubkey'); + + return Buffer.from(pubkey); + } + + /** + * Compute the secret key locking the funds sent to a silent payment address. + * @param spendPrivKey spend private key of the silent payment address + * @param index index of the silent payment address. + * @param ecdhSharedSecret ecdh shared secret identifying the transaction + * @returns 32 bytes key + */ + secretKey( + spendPrivKey: Buffer, + index: number, + ecdhSharedSecret: Buffer, + ): Buffer { + const hash = hashSharedSecret(ecdhSharedSecret, index); + let privkey = this.ecc.privateAdd(spendPrivKey, hash); + if (!privkey) throw new Error('Invalid privkey'); + + if (this.ecc.pointFromScalar(privkey)?.[0] === 0x03) { + privkey = this.ecc.privateNegate(privkey); + } + + return Buffer.from(privkey); + } +} + +function hashSharedSecret(secret: Buffer, index: number): Buffer { + const serializedIndex = Buffer.allocUnsafe(4); + serializedIndex.writeUint32BE(index, 0); + return sha256(Buffer.concat([secret, serializedIndex])); +} diff --git a/yarn.lock b/yarn.lock index 15675ca86..670ddd05b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -445,7 +445,7 @@ base-x@^3.0.2, base-x@^3.0.6: bech32@^2.0.0: version "2.0.0" - resolved "https://registry.npmjs.org/bech32/-/bech32-2.0.0.tgz" + resolved "https://registry.yarnpkg.com/bech32/-/bech32-2.0.0.tgz#078d3686535075c8c79709f054b1b226a133b355" integrity sha512-LcknSilhIGatDAsY1ak2I8VtGaHNhgMSYVxFrGLXv+xLHytaKZKcaUJJUE7qmBr7h33o5YQwP55pMI0xmkpJwg== binary-extensions@^2.0.0: @@ -1810,10 +1810,10 @@ test-exclude@^6.0.0: glob "^7.1.4" minimatch "^3.0.4" -tiny-secp256k1@^2.2.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/tiny-secp256k1/-/tiny-secp256k1-2.2.1.tgz#a61d4791b7031aa08a9453178a131349c3e10f9b" - integrity sha512-/U4xfVqnVxJXN4YVsru0E6t5wVncu2uunB8+RVR40fYUxkKYUPS10f+ePQZgFBoE/Jbf9H1NBveupF2VmB58Ng== +tiny-secp256k1@^2.2.3: + version "2.2.3" + resolved "https://registry.yarnpkg.com/tiny-secp256k1/-/tiny-secp256k1-2.2.3.tgz#fe1dde11a64fcee2091157d4b78bcb300feb9b65" + integrity sha512-SGcL07SxcPN2nGKHTCvRMkQLYPSoeFcvArUSCYtjVARiFAWU44cCIqYS0mYAU6nY7XfvwURuTIGo2Omt3ZQr0Q== dependencies: uint8array-tools "0.0.7"