From 399b698b0dca3c4d6779d35f99c7d3090cc96a9a Mon Sep 17 00:00:00 2001 From: smartgoo Date: Thu, 24 Oct 2024 16:45:18 -0400 Subject: [PATCH] Python Tx Generator (#109) * generator checkpoint * generator checkpoint * bug fixes * krc20 deploy comment * .pyi * rust 1.82 * krc20 deploy example cleanup --- Cargo.lock | 3 + cli/src/cli.rs | 4 +- consensus/core/src/hashing/wasm.rs | 4 + crypto/txscript/src/python/builder.rs | 7 +- python/examples/transactions/krc20_deploy.py | 90 +++- python/kaspa.pyi | 422 +++++++++++++++++- python/src/lib.rs | 5 + wallet/core/Cargo.toml | 8 + wallet/core/src/error.rs | 9 + .../core/src/python/tx/generator/generator.rs | 173 +++++++ wallet/core/src/python/tx/generator/mod.rs | 7 + .../core/src/python/tx/generator/pending.rs | 135 ++++++ .../core/src/python/tx/generator/summary.rs | 60 +++ wallet/core/src/python/tx/mod.rs | 1 + wallet/core/src/python/tx/utils.rs | 36 ++ wallet/core/src/tx/payment.rs | 9 + 16 files changed, 943 insertions(+), 30 deletions(-) create mode 100644 wallet/core/src/python/tx/generator/generator.rs create mode 100644 wallet/core/src/python/tx/generator/mod.rs create mode 100644 wallet/core/src/python/tx/generator/pending.rs create mode 100644 wallet/core/src/python/tx/generator/summary.rs diff --git a/Cargo.lock b/Cargo.lock index cfd6ce1037..9694b048e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3474,6 +3474,7 @@ dependencies = [ "kaspa-hashes", "kaspa-metrics-core", "kaspa-notify", + "kaspa-python-macros", "kaspa-rpc-core", "kaspa-txscript", "kaspa-txscript-errors", @@ -3483,11 +3484,13 @@ dependencies = [ "kaspa-wallet-pskt", "kaspa-wasm-core", "kaspa-wrpc-client", + "kaspa-wrpc-python", "kaspa-wrpc-wasm", "md-5", "pad", "pbkdf2", "pyo3", + "pyo3-asyncio-0-21", "rand 0.8.5", "regex", "ripemd", diff --git a/cli/src/cli.rs b/cli/src/cli.rs index 5ca1997ea3..a32956740a 100644 --- a/cli/src/cli.rs +++ b/cli/src/cli.rs @@ -1016,7 +1016,7 @@ mod panic_handler { fn stack(error: &Error) -> String; } - pub fn process(info: &std::panic::PanicInfo) -> String { + pub fn process(info: &std::panic::PanicHookInfo) -> String { let mut msg = info.to_string(); // Add the error stack to our message. @@ -1053,7 +1053,7 @@ mod panic_handler { impl KaspaCli { pub fn init_panic_hook(self: &Arc) { let this = self.clone(); - let handler = move |info: &std::panic::PanicInfo| { + let handler = move |info: &std::panic::PanicHookInfo| { let msg = panic_handler::process(info); this.term().writeln(msg.crlf()); panic_handler::console_error(msg); diff --git a/consensus/core/src/hashing/wasm.rs b/consensus/core/src/hashing/wasm.rs index 4c9c94b223..d75854ca5a 100644 --- a/consensus/core/src/hashing/wasm.rs +++ b/consensus/core/src/hashing/wasm.rs @@ -1,9 +1,13 @@ use super::sighash_type::{self, SigHashType}; +#[cfg(feature = "py-sdk")] +use pyo3::prelude::*; use wasm_bindgen::prelude::*; /// Kaspa Sighash types allowed by consensus /// @category Consensus +#[cfg_attr(feature = "py-sdk", pyclass)] #[wasm_bindgen] +#[derive(Clone, Copy)] pub enum SighashType { All, None, diff --git a/crypto/txscript/src/python/builder.rs b/crypto/txscript/src/python/builder.rs index 399a1cc475..08adb37e12 100644 --- a/crypto/txscript/src/python/builder.rs +++ b/crypto/txscript/src/python/builder.rs @@ -117,10 +117,13 @@ impl ScriptBuilder { #[pyo3(name = "encode_pay_to_script_hash_signature_script")] pub fn pay_to_script_hash_signature_script(&self, signature: String) -> Result { + // PY-TODO use PyBinary + let mut signature_bytes = vec![0u8; signature.len() / 2]; + faster_hex::hex_decode(signature.as_bytes(), &mut signature_bytes).unwrap(); + let inner = self.inner(); let script = inner.script(); - let signature = signature.as_bytes(); - let generated_script = standard::pay_to_script_hash_signature_script(script.into(), signature.to_vec())?; + let generated_script = standard::pay_to_script_hash_signature_script(script.into(), signature_bytes)?; Ok(generated_script.to_hex().into()) } diff --git a/python/examples/transactions/krc20_deploy.py b/python/examples/transactions/krc20_deploy.py index 6eeb1fd816..82c0ec0d3e 100644 --- a/python/examples/transactions/krc20_deploy.py +++ b/python/examples/transactions/krc20_deploy.py @@ -8,46 +8,94 @@ RpcClient, ScriptBuilder, address_from_script_public_key, - create_transaction, - sign_transaction + create_transactions, ) + async def main(): - private_key = PrivateKey("389840d7696e89c38856a066175e8e92697f0cf182b854c883237a50acaf1f69") - keypair = private_key.to_keypair() - address = keypair.to_address("kaspatest") + client = RpcClient(resolver=Resolver(), network='testnet', network_suffix=10) + await client.connect() + + private_key = PrivateKey('389840d7696e89c38856a066175e8e92697f0cf182b854c883237a50acaf1f69') + public_key = private_key.to_public_key() + address = public_key.to_address('testnet') + print(f'Address: {address.to_string()}') + print(f'XOnly Pub Key: {public_key.to_x_only_public_key().to_string()}') ###################### # Commit tx data = { - "p": "krc-20", - "op": "deploy", - "tick": "PYSDK", - "max": "112121115100107", - "lim":" 1000", + 'p': 'krc-20', + 'op': 'deploy', + 'tick': 'TPYSDK', + 'max': '112121115100107', + 'lim': '1000', } script = ScriptBuilder() - script.add_data(keypair.public_key) + script.add_data(public_key.to_x_only_public_key().to_string()) script.add_op(Opcodes.OpCheckSig) script.add_op(Opcodes.OpFalse) script.add_op(Opcodes.OpIf) - script.add_data(b"kasplex") + script.add_data(b'kasplex') script.add_i64(0) script.add_data(json.dumps(data, separators=(',', ':')).encode('utf-8')) script.add_op(Opcodes.OpEndIf) + print(f'Script: {script.to_string()}') - print(script.to_string()) - - p2sh_address = address_from_script_public_key(script.create_pay_to_script_hash_script(), "kaspatest") - print(p2sh_address.to_string()) + p2sh_address = address_from_script_public_key(script.create_pay_to_script_hash_script(), 'kaspatest') + print(f'P2SH Address: {p2sh_address.to_string()}') - # TODO tx submission + utxos = await client.get_utxos_by_addresses(request={'addresses': [address]}) - ###################### + commit_txs = create_transactions( + priority_entries=[], + entries=utxos["entries"], + outputs=[{ 'address': p2sh_address.to_string(), 'amount': 1 * 100_000_000 }], + change_address=address, + priority_fee=1 * 100_000_000, + network_id='testnet-10' + ) + + commit_tx_id = None + for transaction in commit_txs['transactions']: + transaction.sign([private_key], False) + commit_tx_id = await transaction.submit(client) + print('Commit TX ID:', commit_tx_id) + + await asyncio.sleep(10) + + ##################### # Reveal tx - # TODO -if __name__ == "__main__": - asyncio.run(main()) \ No newline at end of file + utxos = await client.get_utxos_by_addresses(request={'addresses': [address]}) + reveal_utxos = await client.get_utxos_by_addresses(request={'addresses': [p2sh_address]}) + + for entry in reveal_utxos['entries']: + if entry['outpoint']['transactionId'] == commit_tx_id: + reveal_utxos = entry + + reveal_txs = create_transactions( + priority_entries=[reveal_utxos], + entries=utxos['entries'], + outputs=[], + change_address=address, + priority_fee=1005 * 100_000_000, + network_id='testnet-10' + ) + + for transaction in reveal_txs['transactions']: + transaction.sign([private_key], False) + + commit_output = next((i for i, input in enumerate(transaction.transaction.inputs) + if input.signature_script == ''), None) + + if commit_output is not None: + sig = transaction.create_input_signature(commit_output, private_key) + transaction.fill_input(commit_output, script.encode_pay_to_script_hash_signature_script(sig)) + + print('Reveal TX ID:', await transaction.submit(client)) + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/python/kaspa.pyi b/python/kaspa.pyi index 38a0d767f1..fb96d0c4b7 100644 --- a/python/kaspa.pyi +++ b/python/kaspa.pyi @@ -24,6 +24,15 @@ class Address: def short(self, n: int) -> str: ... +class SighashType(Enum): + All = 1 + 'None' = 2 + Single = 3 + AllAnyOneCanPay = 4 + NoneAnyOneCanPay = 5 + SingleAnyOneCanPay = 6 + + class ScriptPublicKey: def __init__(self, version: int, script: str) -> None: ... @@ -160,6 +169,19 @@ class TransactionOutput: def script_public_key(self, v: ScriptPublicKey) -> None: ... +class UtxoEntries: + + @property + def items(self) -> list[UtxoEntryReference]: ... + + @items.setter + def items(self, v: list[UtxoEntryReference]): ... + + def sort(self) -> None: ... + + def amount(self) -> int: ... + + class UtxoEntry: @property @@ -251,9 +273,9 @@ class ScriptBuilder: @staticmethod def from_script(script: Union[str, bytes, list[int]]) -> ScriptBuilder: ... - def add_op(self, op: Union[Opcode, int]) -> ScriptBuilder: ... + def add_op(self, op: Union[Opcodes, int]) -> ScriptBuilder: ... - def add_ops(self, opcodes: Union[list[Opcode], list[int]]) -> ScriptBuilder: ... + def add_ops(self, opcodes: Union[list[Opcodes], list[int]]) -> ScriptBuilder: ... def add_data(self, data: Union[str, bytes, list[int]]) -> ScriptBuilder: ... @@ -272,11 +294,387 @@ class ScriptBuilder: def create_pay_to_script_hash_script(self) -> ScriptPublicKey: ... - def encode_pay_to_script_hash_signature_script(self) -> str: ... + def encode_pay_to_script_hash_signature_script(self, signature: str) -> str: ... + + +class Opcodes(Enum): + OpFalse = 0x00 + + OpData1 = 0x01 + OpData2 = 0x02 + OpData3 = 0x03 + OpData4 = 0x04 + OpData5 = 0x05 + OpData6 = 0x06 + OpData7 = 0x07 + OpData8 = 0x08 + OpData9 = 0x09 + OpData10 = 0x0a + OpData11 = 0x0b + OpData12 = 0x0c + OpData13 = 0x0d + OpData14 = 0x0e + OpData15 = 0x0f + OpData16 = 0x10 + OpData17 = 0x11 + OpData18 = 0x12 + OpData19 = 0x13 + OpData20 = 0x14 + OpData21 = 0x15 + OpData22 = 0x16 + OpData23 = 0x17 + OpData24 = 0x18 + OpData25 = 0x19 + OpData26 = 0x1a + OpData27 = 0x1b + OpData28 = 0x1c + OpData29 = 0x1d + OpData30 = 0x1e + OpData31 = 0x1f + OpData32 = 0x20 + OpData33 = 0x21 + OpData34 = 0x22 + OpData35 = 0x23 + OpData36 = 0x24 + OpData37 = 0x25 + OpData38 = 0x26 + OpData39 = 0x27 + OpData40 = 0x28 + OpData41 = 0x29 + OpData42 = 0x2a + OpData43 = 0x2b + OpData44 = 0x2c + OpData45 = 0x2d + OpData46 = 0x2e + OpData47 = 0x2f + OpData48 = 0x30 + OpData49 = 0x31 + OpData50 = 0x32 + OpData51 = 0x33 + OpData52 = 0x34 + OpData53 = 0x35 + OpData54 = 0x36 + OpData55 = 0x37 + OpData56 = 0x38 + OpData57 = 0x39 + OpData58 = 0x3a + OpData59 = 0x3b + OpData60 = 0x3c + OpData61 = 0x3d + OpData62 = 0x3e + OpData63 = 0x3f + OpData64 = 0x40 + OpData65 = 0x41 + OpData66 = 0x42 + OpData67 = 0x43 + OpData68 = 0x44 + OpData69 = 0x45 + OpData70 = 0x46 + OpData71 = 0x47 + OpData72 = 0x48 + OpData73 = 0x49 + OpData74 = 0x4a + OpData75 = 0x4b + + OpPushData1 = 0x4c + OpPushData2 = 0x4d + OpPushData4 = 0x4e + + Op1Negate = 0x4f + + OpReserved = 0x50 + + OpTrue = 0x51 + + Op2 = 0x52 + Op3 = 0x53 + Op4 = 0x54 + Op5 = 0x55 + Op6 = 0x56 + Op7 = 0x57 + Op8 = 0x58 + Op9 = 0x59 + Op10 = 0x5a + Op11 = 0x5b + Op12 = 0x5c + Op13 = 0x5d + Op14 = 0x5e + Op15 = 0x5f + Op16 = 0x60 + + OpNop = 0x61 + OpVer = 0x62 + OpIf = 0x63 + OpNotIf = 0x64 + OpVerIf = 0x65 + OpVerNotIf = 0x66 + + OpElse = 0x67 + OpEndIf = 0x68 + OpVerify = 0x69 + OpReturn = 0x6a + OpToAltStack = 0x6b + OpFromAltStack = 0x6c + + Op2Drop = 0x6d + Op2Dup = 0x6e + Op3Dup = 0x6f + Op2Over = 0x70 + Op2Rot = 0x71 + Op2Swap = 0x72 + OpIfDup = 0x73 + OpDepth = 0x74 + OpDrop = 0x75 + OpDup = 0x76 + OpNip = 0x77 + OpOver = 0x78 + OpPick = 0x79 + + OpRoll = 0x7a + OpRot = 0x7b + OpSwap = 0x7c + OpTuck = 0x7d + + # Splice opcodes. + OpCat = 0x7e + OpSubStr = 0x7f + OpLeft = 0x80 + OpRight = 0x81 + + OpSize = 0x82 + + # Bitwise logic opcodes. + OpInvert = 0x83 + OpAnd = 0x84 + OpOr = 0x85 + OpXor = 0x86 + + OpEqual = 0x87 + OpEqualVerify = 0x88 + + OpReserved1 = 0x89 + OpReserved2 = 0x8a + + # Numeric related opcodes. + Op1Add = 0x8b + Op1Sub = 0x8c + Op2Mul = 0x8d + Op2Div = 0x8e + OpNegate = 0x8f + OpAbs = 0x90 + OpNot = 0x91 + Op0NotEqual = 0x92 + + OpAdd = 0x93 + OpSub = 0x94 + OpMul = 0x95 + OpDiv = 0x96 + OpMod = 0x97 + OpLShift = 0x98 + OpRShift = 0x99 + + OpBoolAnd = 0x9a + OpBoolOr = 0x9b + + OpNumEqual = 0x9c + OpNumEqualVerify = 0x9d + OpNumNotEqual = 0x9e + + OpLessThan = 0x9f + OpGreaterThan = 0xa0 + OpLessThanOrEqual = 0xa1 + OpGreaterThanOrEqual = 0xa2 + OpMin = 0xa3 + OpMax = 0xa4 + OpWithin = 0xa5 + + # Undefined opcodes. + OpUnknown166 = 0xa6 + OpUnknown167 = 0xa7 + + # Crypto opcodes. + OpSHA256 = 0xa8 + + OpCheckMultiSigECDSA = 0xa9 + + OpBlake2b = 0xaa + OpCheckSigECDSA = 0xab + OpCheckSig = 0xac + OpCheckSigVerify = 0xad + OpCheckMultiSig = 0xae + OpCheckMultiSigVerify = 0xaf + OpCheckLockTimeVerify = 0xb0 + OpCheckSequenceVerify = 0xb1 + + # Undefined opcodes. + OpUnknown178 = 0xb2 + OpUnknown179 = 0xb3 + OpUnknown180 = 0xb4 + OpUnknown181 = 0xb5 + OpUnknown182 = 0xb6 + OpUnknown183 = 0xb7 + OpUnknown184 = 0xb8 + OpUnknown185 = 0xb9 + OpUnknown186 = 0xba + OpUnknown187 = 0xbb + OpUnknown188 = 0xbc + OpUnknown189 = 0xbd + OpUnknown190 = 0xbe + OpUnknown191 = 0xbf + OpUnknown192 = 0xc0 + OpUnknown193 = 0xc1 + OpUnknown194 = 0xc2 + OpUnknown195 = 0xc3 + OpUnknown196 = 0xc4 + OpUnknown197 = 0xc5 + OpUnknown198 = 0xc6 + OpUnknown199 = 0xc7 + OpUnknown200 = 0xc8 + OpUnknown201 = 0xc9 + OpUnknown202 = 0xca + OpUnknown203 = 0xcb + OpUnknown204 = 0xcc + OpUnknown205 = 0xcd + OpUnknown206 = 0xce + OpUnknown207 = 0xcf + OpUnknown208 = 0xd0 + OpUnknown209 = 0xd1 + OpUnknown210 = 0xd2 + OpUnknown211 = 0xd3 + OpUnknown212 = 0xd4 + OpUnknown213 = 0xd5 + OpUnknown214 = 0xd6 + OpUnknown215 = 0xd7 + OpUnknown216 = 0xd8 + OpUnknown217 = 0xd9 + OpUnknown218 = 0xda + OpUnknown219 = 0xdb + OpUnknown220 = 0xdc + OpUnknown221 = 0xdd + OpUnknown222 = 0xde + OpUnknown223 = 0xdf + OpUnknown224 = 0xe0 + OpUnknown225 = 0xe1 + OpUnknown226 = 0xe2 + OpUnknown227 = 0xe3 + OpUnknown228 = 0xe4 + OpUnknown229 = 0xe5 + OpUnknown230 = 0xe6 + OpUnknown231 = 0xe7 + OpUnknown232 = 0xe8 + OpUnknown233 = 0xe9 + OpUnknown234 = 0xea + OpUnknown235 = 0xeb + OpUnknown236 = 0xec + OpUnknown237 = 0xed + OpUnknown238 = 0xee + OpUnknown239 = 0xef + OpUnknown240 = 0xf0 + OpUnknown241 = 0xf1 + OpUnknown242 = 0xf2 + OpUnknown243 = 0xf3 + OpUnknown244 = 0xf4 + OpUnknown245 = 0xf5 + OpUnknown246 = 0xf6 + OpUnknown247 = 0xf7 + OpUnknown248 = 0xf8 + OpUnknown249 = 0xf9 + + OpSmallInteger = 0xfa + OpPubKeys = 0xfb + OpUnknown252 = 0xfc + OpPubKeyHash = 0xfd + OpPubKey = 0xfe + OpInvalidOpCode = 0xff + + +class Generator: + + def __init__( + self, + network_id: str, + entries: list[dict], + outputs: list[dict], + change_address: Address, + payload: Optional[str], + priority_fee: Optional[str], + priority_entries: Optional[list[dict]], + sig_op_count: Optional[int], + minimun_signatures: Optional[int] + ) -> None: ... + + def summary(self) -> GeneratorSummary: ... + + # __iter__ TODO + # __next__ TODO + + +class PendingTransaction: + + @property + def id(self) -> str: ... + + @property + def payment_amount(self) -> Optional[int]: ... + + @property + def change_amount(self) -> int: ... + + @property + def fee_amount(self) -> int: ... + + @property + def mass(self) -> int: ... + + @property + def minimum_signatures(self) -> int: ... + + @property + def aggregate_input_amount(self) -> int: ... + + @property + def aggregate_output_amount(self) -> int: ... + + @property + def transaction_type(self) -> str: ... + + def addresses(self) -> list[Address]: ... + + def get_utxo_entries(self) -> list[UtxoEntryReference]: ... + def create_input_signature(self, input_index: int, private_key: PrivateKey, sighash_type: Optional[SighashType]) -> str: ... -class Opcodes(Enum): ... - # TODO + def fill_input(self, input_index: int, signature_script: str) -> None: ... + + def sign_input(self, input_index: int, private_key: PrivateKey, sighash_type: Optional[SighashType]) -> None: ... + + def sign(self, private_keys: list[PrivateKey], check_fully_signed: Optional[bool]) -> None: ... + + def submit(self, rpc_client: RpcClient) -> str: ... + + @property + def transaction(self) -> Transaction: ... + + +class GeneratorSummary: + + @property + def network_type(self) -> str: ... + + @property + def utxos(self) -> int: ... + + @property + def fees(self) -> int: ... + + @property + def transactions(self) -> int: ... + + @property + def final_amount(self) -> Optional[int]: ... + + @property + def final_transaction_id(self) -> Optional[str]: ... class PaymentOutput: @@ -292,6 +690,20 @@ def create_transaction( sig_op_count: Optional[int] ) -> Transaction: ... + +def create_transactions( + network_id: str, + entries: list[dict], + outputs: list[dict], + change_address: Address, + payload: Optional[str], + priority_fee: Optional[int], + priority_entries: Optional[list[dict]], + sig_op_count: Optional[int], + minimum_signatures: Optional[int] +) -> dict: ... + + def sign_transaction(tx: Transaction, signer: list[PrivateKey], verify_sig: bool) -> Transaction: ... diff --git a/python/src/lib.rs b/python/src/lib.rs index 5f40d45128..1df6565d22 100644 --- a/python/src/lib.rs +++ b/python/src/lib.rs @@ -6,6 +6,7 @@ cfg_if::cfg_if! { fn kaspa(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; @@ -25,8 +26,12 @@ cfg_if::cfg_if! { m.add_class::()?; m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_function(wrap_pyfunction!(kaspa_wallet_core::python::tx::utils::create_transaction_py, m)?)?; + m.add_function(wrap_pyfunction!(kaspa_wallet_core::python::tx::utils::create_transactions_py, m)?)?; m.add_function(wrap_pyfunction!(kaspa_wallet_core::python::signer::py_sign_transaction, m)?)?; m.add_class::()?; diff --git a/wallet/core/Cargo.toml b/wallet/core/Cargo.toml index 5ddd20b182..a7e7fef23d 100644 --- a/wallet/core/Cargo.toml +++ b/wallet/core/Cargo.toml @@ -27,7 +27,12 @@ wasm32-sdk = [ ] default = ["wasm32-sdk"] py-sdk = [ + "kaspa-consensus-core/py-sdk", + "kaspa-python-macros", + "kaspa-wallet-keys/py-sdk", + "kaspa-wrpc-python/py-sdk", "pyo3", + "pyo3-asyncio-0-21", "serde-pyobject", ] # default = [] @@ -69,6 +74,7 @@ kaspa-core.workspace = true kaspa-hashes.workspace = true kaspa-metrics-core.workspace = true kaspa-notify.workspace = true +kaspa-python-macros = { workspace = true, optional = true } kaspa-rpc-core.workspace = true kaspa-txscript-errors.workspace = true kaspa-txscript.workspace = true @@ -78,11 +84,13 @@ kaspa-wallet-macros.workspace = true kaspa-wallet-pskt.workspace = true kaspa-wasm-core.workspace = true kaspa-wrpc-client.workspace = true +kaspa-wrpc-python = { workspace = true, optional = true } kaspa-wrpc-wasm.workspace = true md-5.workspace = true pad.workspace = true pbkdf2.workspace = true pyo3 = { workspace = true, optional = true } +pyo3-asyncio-0-21 = { workspace = true, optional = true } rand.workspace = true regex.workspace = true ripemd.workspace = true diff --git a/wallet/core/src/error.rs b/wallet/core/src/error.rs index 8992a8a924..ecaca1c973 100644 --- a/wallet/core/src/error.rs +++ b/wallet/core/src/error.rs @@ -9,6 +9,8 @@ use kaspa_bip32::Error as BIP32Error; use kaspa_consensus_core::sign::Error as CoreSignError; use kaspa_rpc_core::RpcError as KaspaRpcError; use kaspa_wrpc_client::error::Error as KaspaWorkflowRpcError; +#[cfg(feature = "py-sdk")] +use pyo3::{exceptions::PyException, prelude::*}; use std::sync::PoisonError; use thiserror::Error; use wasm_bindgen::JsValue; @@ -441,3 +443,10 @@ impl From> for Error { Error::Custom(e.to_string()) } } + +#[cfg(feature = "py-sdk")] +impl From for PyErr { + fn from(value: Error) -> Self { + PyException::new_err(value.to_string()) + } +} diff --git a/wallet/core/src/python/tx/generator/generator.rs b/wallet/core/src/python/tx/generator/generator.rs new file mode 100644 index 0000000000..074737a732 --- /dev/null +++ b/wallet/core/src/python/tx/generator/generator.rs @@ -0,0 +1,173 @@ +use crate::imports::*; +use crate::python::tx::generator::pending::PendingTransaction; +use crate::python::tx::generator::summary::GeneratorSummary; +use crate::tx::{generator as native, Fees, PaymentDestination, PaymentOutputs}; + +#[pyclass] +pub struct Generator { + inner: Arc, +} + +#[pymethods] +impl Generator { + #[new] + pub fn ctor( + network_id: String, // TODO this is wrong + entries: Vec<&PyDict>, + outputs: Vec<&PyDict>, + change_address: Address, + payload: Option, // TODO Hex string for now, use PyBinary + priority_fee: Option, + priority_entries: Option>, + sig_op_count: Option, + minimum_signatures: Option, + ) -> PyResult { + let settings = GeneratorSettings::new( + outputs, + change_address, + priority_fee, + entries, + priority_entries, + sig_op_count, + minimum_signatures, + payload, + network_id, + ); + + let settings = match settings.source { + GeneratorSource::UtxoEntries(utxo_entries) => { + let change_address = settings + .change_address + .ok_or_else(|| PyException::new_err("changeAddress is required for Generator constructor with UTXO entries"))?; + + let network_id = settings + .network_id + .ok_or_else(|| PyException::new_err("networkId is required for Generator constructor with UTXO entries"))?; + + native::GeneratorSettings::try_new_with_iterator( + network_id, + Box::new(utxo_entries.into_iter()), + settings.priority_utxo_entries, + change_address, + settings.sig_op_count, + settings.minimum_signatures, + settings.final_transaction_destination, + settings.final_priority_fee, + settings.payload, + settings.multiplexer, + )? + } + GeneratorSource::UtxoContext(_) => unimplemented!(), + }; + + let abortable = Abortable::default(); + let generator = native::Generator::try_new(settings, None, Some(&abortable))?; + + Ok(Self { inner: Arc::new(generator) }) + } + + pub fn summary(&self) -> GeneratorSummary { + self.inner.summary().into() + } +} + +impl Generator { + pub fn iter(&self) -> impl Iterator> { + self.inner.iter() + } + + pub fn stream(&self) -> impl Stream> { + self.inner.stream() + } +} + +#[pymethods] +impl Generator { + fn __iter__(slf: PyRefMut) -> PyResult> { + Ok(slf.into()) + } + + fn __next__(slf: PyRefMut) -> PyResult> { + match slf.inner.iter().next() { + Some(result) => match result { + Ok(transaction) => Ok(Some(transaction.into())), + Err(e) => Err(PyErr::new::(format!("{}", e))), + }, + None => Ok(None), + } + } +} + +enum GeneratorSource { + UtxoEntries(Vec), + UtxoContext(UtxoContext), + // #[cfg(any(feature = "wasm32-sdk"), not(target_arch = "wasm32"))] + // Account(Account), +} + +struct GeneratorSettings { + pub network_id: Option, + pub source: GeneratorSource, + pub priority_utxo_entries: Option>, + pub multiplexer: Option>>, + pub final_transaction_destination: PaymentDestination, + pub change_address: Option
, + pub final_priority_fee: Fees, + pub sig_op_count: u8, + pub minimum_signatures: u16, + pub payload: Option>, +} + +impl GeneratorSettings { + pub fn new( + outputs: Vec<&PyDict>, + change_address: Address, + priority_fee: Option, + entries: Vec<&PyDict>, + priority_entries: Option>, + sig_op_count: Option, + minimum_signatures: Option, + payload: Option, // TODO Hex string for now, use PyBinary + network_id: String, // TODO this is wrong + ) -> GeneratorSettings { + let network_id = NetworkId::from_str(&network_id).unwrap(); + + // let final_transaction_destination: PaymentDestination = + // if outputs.is_empty() { PaymentDestination::Change } else { PaymentOutputs::try_from(outputs).unwrap().into() }; + let final_transaction_destination: PaymentDestination = PaymentOutputs::try_from(outputs).unwrap().into(); + + let final_priority_fee = match priority_fee { + Some(fee) => fee.try_into().unwrap(), + None => Fees::None, + }; + + // TODO support GeneratorSource::UtxoContext and clean up below + let generator_source = + GeneratorSource::UtxoEntries(entries.iter().map(|entry| UtxoEntryReference::try_from(*entry).unwrap()).collect()); + + let priority_utxo_entries = if let Some(entries) = priority_entries { + Some(entries.iter().map(|entry| UtxoEntryReference::try_from(*entry).unwrap()).collect()) + } else { + None + }; + + let sig_op_count = sig_op_count.unwrap_or(1); + + let minimum_signatures = minimum_signatures.unwrap_or(1); + + let payload = payload.map(|s| s.into_bytes()); + + GeneratorSettings { + network_id: Some(network_id), + source: generator_source, + priority_utxo_entries, + multiplexer: None, + final_transaction_destination, + change_address: Some(change_address), + final_priority_fee, + sig_op_count, + minimum_signatures, + payload, + } + } +} diff --git a/wallet/core/src/python/tx/generator/mod.rs b/wallet/core/src/python/tx/generator/mod.rs new file mode 100644 index 0000000000..9bb85ea89c --- /dev/null +++ b/wallet/core/src/python/tx/generator/mod.rs @@ -0,0 +1,7 @@ +pub mod generator; +pub mod pending; +pub mod summary; + +pub use generator::*; +pub use pending::*; +pub use summary::*; diff --git a/wallet/core/src/python/tx/generator/pending.rs b/wallet/core/src/python/tx/generator/pending.rs new file mode 100644 index 0000000000..613de3caf4 --- /dev/null +++ b/wallet/core/src/python/tx/generator/pending.rs @@ -0,0 +1,135 @@ +use crate::imports::*; +use crate::tx::generator as native; +use kaspa_consensus_client::Transaction; +use kaspa_consensus_core::hashing::wasm::SighashType; +use kaspa_python_macros::py_async; +use kaspa_wallet_keys::privatekey::PrivateKey; +use kaspa_wrpc_python::client::RpcClient; + +#[pyclass] +pub struct PendingTransaction { + inner: native::PendingTransaction, +} + +#[pymethods] +impl PendingTransaction { + #[getter] + fn id(&self) -> String { + self.inner.id().to_string() + } + + #[getter] + #[pyo3(name = "payment_amount")] + fn payment_value(&self) -> Option { + self.inner.payment_value() + } + + #[getter] + #[pyo3(name = "change_amount")] + fn change_value(&self) -> u64 { + self.inner.change_value() + } + + #[getter] + #[pyo3(name = "fee_amount")] + fn fees(&self) -> u64 { + self.inner.fees() + } + + #[getter] + fn mass(&self) -> u64 { + self.inner.mass() + } + + #[getter] + fn minimum_signatures(&self) -> u16 { + self.inner.minimum_signatures() + } + + #[getter] + #[pyo3(name = "aggregate_input_amount")] + fn aggregate_input_value(&self) -> u64 { + self.inner.aggregate_input_value() + } + + #[getter] + #[pyo3(name = "aggregate_output_amount")] + fn aggregate_output_value(&self) -> u64 { + self.inner.aggregate_output_value() + } + + #[getter] + #[pyo3(name = "transaction_type")] + fn kind(&self) -> String { + if self.inner.is_batch() { + "batch".to_string() + } else { + "final".to_string() + } + } + + fn addresses(&self) -> Vec
{ + self.inner.addresses().clone() + } + + fn get_utxo_entries(&self) -> Vec { + self.inner.utxo_entries().values().map(|utxo_entry| UtxoEntryReference::from(utxo_entry.clone())).collect() + } + + fn create_input_signature(&self, input_index: u8, private_key: &PrivateKey, sighash_type: Option<&SighashType>) -> Result { + let signature = self.inner.create_input_signature( + input_index.into(), + &private_key.secret_bytes(), + sighash_type.cloned().unwrap_or(SighashType::All).into(), + )?; + + Ok(signature.to_hex()) + } + + fn fill_input(&self, input_index: u8, signature_script: String) -> Result<()> { + // TODO use PyBinary for signature_script + let mut bytes = vec![0u8; signature_script.len() / 2]; + faster_hex::hex_decode(signature_script.as_bytes(), &mut bytes).unwrap(); + self.inner.fill_input(input_index.into(), bytes)?; + + Ok(()) + } + + fn sign_input(&self, input_index: u8, private_key: &PrivateKey, sighash_type: Option<&SighashType>) -> Result<()> { + self.inner.sign_input( + input_index.into(), + &private_key.secret_bytes(), + sighash_type.cloned().unwrap_or(SighashType::All).into(), + )?; + + Ok(()) + } + + fn sign(&self, private_keys: Vec, check_fully_signed: Option) -> Result<()> { + let mut keys = private_keys.iter().map(|key| key.secret_bytes()).collect::>(); + self.inner.try_sign_with_keys(&keys, check_fully_signed)?; + keys.zeroize(); + Ok(()) + } + + fn submit(&self, py: Python, rpc_client: &RpcClient) -> PyResult> { + let inner = self.inner.clone(); + let rpc: Arc = rpc_client.client().clone(); + + py_async! {py, async move { + let txid = inner.try_submit(&rpc).await?; + Ok(txid.to_string()) + }} + } + + #[getter] + fn transaction(&self) -> Result { + Ok(Transaction::from_cctx_transaction(&self.inner.transaction(), self.inner.utxo_entries())) + } +} + +impl From for PendingTransaction { + fn from(pending_transaction: native::PendingTransaction) -> Self { + Self { inner: pending_transaction } + } +} diff --git a/wallet/core/src/python/tx/generator/summary.rs b/wallet/core/src/python/tx/generator/summary.rs new file mode 100644 index 0000000000..2f0a1198d9 --- /dev/null +++ b/wallet/core/src/python/tx/generator/summary.rs @@ -0,0 +1,60 @@ +use crate::imports::*; +use crate::tx::generator as core; + +/// +/// A class containing a summary produced by transaction {@link Generator}. +/// This class contains the number of transactions, the aggregated fees, +/// the aggregated UTXOs and the final transaction amount that includes +/// both network and QoS (priority) fees. +/// +/// @see {@link createTransactions}, {@link IGeneratorSettingsObject}, {@link Generator} +/// @category Wallet SDK +/// +#[pyclass] +pub struct GeneratorSummary { + inner: core::GeneratorSummary, +} + +#[pymethods] +impl GeneratorSummary { + #[getter] + pub fn network_type(&self) -> String { + self.inner.network_type().to_string() + } + + #[getter] + #[pyo3(name = "utxos")] + pub fn aggregated_utxos(&self) -> usize { + self.inner.aggregated_utxos() + } + + #[getter] + #[pyo3(name = "fees")] + pub fn aggregated_fees(&self) -> u64 { + self.inner.aggregated_fees() + } + + #[getter] + #[pyo3(name = "transactions")] + pub fn number_of_generated_transactions(&self) -> usize { + self.inner.number_of_generated_transactions() + } + + #[getter] + #[pyo3(name = "final_amount")] + pub fn final_transaction_amount(&self) -> Option { + self.inner.final_transaction_amount() + } + + #[getter] + #[pyo3(name = "final_transaction_id")] + pub fn final_transaction_id(&self) -> Option { + self.inner.final_transaction_id().map(|id| id.to_string()) + } +} + +impl From for GeneratorSummary { + fn from(inner: core::GeneratorSummary) -> Self { + Self { inner } + } +} diff --git a/wallet/core/src/python/tx/mod.rs b/wallet/core/src/python/tx/mod.rs index b5614dd823..122f04e3f1 100644 --- a/wallet/core/src/python/tx/mod.rs +++ b/wallet/core/src/python/tx/mod.rs @@ -1 +1,2 @@ +pub mod generator; pub mod utils; diff --git a/wallet/core/src/python/tx/utils.rs b/wallet/core/src/python/tx/utils.rs index 1d874611f6..c50a3d87b6 100644 --- a/wallet/core/src/python/tx/utils.rs +++ b/wallet/core/src/python/tx/utils.rs @@ -1,4 +1,5 @@ use crate::imports::*; +use crate::python::tx::generator::{Generator, PendingTransaction}; use crate::tx::payment::PaymentOutput; use kaspa_consensus_client::*; use kaspa_consensus_core::subnets::SUBNETWORK_ID_NATIVE; @@ -43,3 +44,38 @@ pub fn create_transaction_py( Ok(transaction) } + +#[pyfunction] +#[pyo3(name = "create_transactions")] +pub fn create_transactions_py<'a>( + py: Python<'a>, + network_id: String, // TODO this is wrong + entries: Vec<&PyDict>, + outputs: Vec<&PyDict>, + change_address: Address, + payload: Option, // TODO Hex string for now, use PyBinary + priority_fee: Option, + priority_entries: Option>, + sig_op_count: Option, + minimum_signatures: Option, +) -> PyResult> { + let generator = Generator::ctor( + network_id, + entries, + outputs, + change_address, + payload, + priority_fee, + priority_entries, + sig_op_count, + minimum_signatures, + )?; + + let transactions = + generator.iter().map(|r| r.map(PendingTransaction::from).map(|tx| tx.into_py(py))).collect::>>()?; + let summary = generator.summary().into_py(py); + let dict = PyDict::new_bound(py); + dict.set_item("transactions", &transactions)?; + dict.set_item("summary", &summary)?; + Ok(dict) +} diff --git a/wallet/core/src/tx/payment.rs b/wallet/core/src/tx/payment.rs index dec9c7e37b..5c48ded039 100644 --- a/wallet/core/src/tx/payment.rs +++ b/wallet/core/src/tx/payment.rs @@ -209,6 +209,15 @@ impl TryCastFromJs for PaymentOutputs { } } +#[cfg(feature = "py-sdk")] +impl TryFrom> for PaymentOutputs { + type Error = PyErr; + fn try_from(value: Vec<&PyDict>) -> PyResult { + let outputs: Vec = value.iter().map(|utxo| PaymentOutput::try_from(*utxo)).collect::, _>>()?; + Ok(PaymentOutputs { outputs }) + } +} + impl From for Vec { fn from(value: PaymentOutputs) -> Self { value.outputs.into_iter().map(TransactionOutput::from).collect()