Skip to content

Conversation

@IzioDev
Copy link
Collaborator

@IzioDev IzioDev commented Nov 30, 2025

What

This PR is trying to add sign, addresses (get input addresses) and finalize_p2pk to pskt and pskb on wasm exposed struct.
It also add a way of iterating pskts from a pskb.

Since the current rust (native) implementation is tied to wallet & account struct, I had to do it off the native implementation, but ideally we would have the wasm part using the native implementation instead of duplicating logic.

Why

This is needed for pskb/pskt wasm usage, which is needed for KIP-12.

Please, carefully review as this is the first time I contribute to this part of the codebase (which has many specificity i'm still unfamiliar with)

Extra

Fix existing wasm tests and added wasm test suites execution as a Github Action (CI)

Comment on lines +1 to +4
// todo: this is a copy/paste from wallet/core/src/wasm
// i tried to mutualize it in wallet/core/keys, but it conflicted (circular dep with tx_script)
// it also feels overkill to create a package only for that
// need guidance on how to procede with architecturing
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as highlighted

Comment on lines 440 to 442
// todo: find a better way to convert to address
// here we unwrap because we checked network type -> prefix didn't produce error
// but this isn't future proof (if internal implementation change)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as highlighted

@IzioDev IzioDev changed the title feat(wasm): add pskt getter from pskb feat(wasm): pskt & pskb sign, get input addresses and iterate pskts from pskb Nov 30, 2025
Comment on lines 489 to 546
/// Get `Transaction` from a PSKT, by first finalizing it.
/// This method is useful to broadcast PSKT to the Kaspa Network, using `RpcClient.submitTransaction`.
#[wasm_bindgen(js_name = "finalizeAndExtractTransaction")]
pub fn finalize_and_extract_transaction(&self, network_type: &NetworkTypeT) -> Result<Transaction> {
let network_type: NetworkType = network_type.try_into()?;

let pskt_finalizer: Native<Finalizer> = match self.take() {
State::NoOp(inner) => inner.ok_or(Error::NotInitialized)?.into(),
State::Creator(pskt) => pskt.constructor().signer().finalizer(),
State::Constructor(pskt) => pskt.signer().finalizer(),
State::Updater(pskt) => pskt.signer().finalizer(),
State::Signer(pskt) => pskt.finalizer(),
State::Combiner(pskt) => pskt.signer().finalizer(),
State::Finalizer(pskt) => pskt,
state => return Err(Error::state(state))?,
};

let result = pskt_finalizer.finalize_sync(|inner: &Inner| -> Result<Vec<Vec<u8>>> {
Ok(inner
.inputs
.iter()
.map(|input| -> Vec<u8> {
let signatures: Vec<_> = input
.partial_sigs
.clone()
.into_iter()
.flat_map(|(_, signature)| {
iter::once(OpData65).chain(signature.into_bytes()).chain([input.sighash_type.to_u8()])
})
.collect();

signatures
.into_iter()
.chain(
input
.redeem_script
.as_ref()
.map(|redeem_script| ScriptBuilder::new().add_data(redeem_script.as_slice()).unwrap().drain().to_vec())
.unwrap_or_default(),
)
.collect()
})
.collect())
});

let finalized_pskt = match result {
Ok(finalized_pskt) => finalized_pskt,
Err(e) => return Err(Error::from(e.to_string())),
};

let mutable_transaction = finalized_pskt
.extractor()?
.extract_tx(&network_type.into())
.map_err(|e| Error::custom(format!("Failed to extract transaction: {e}")))?;

Ok(mutable_transaction.tx.into())
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the function (or name) is incorrect according to design and purpose. every kaspa script can accept arbitraty arguments. for example multisig expects count and signtures. htlc would expect pre-image and etc. that is why it finalization must be aligned with purpose. here you created a function that according to name is applicable to any script which is wrong.

Copy link
Collaborator Author

@IzioDev IzioDev Dec 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summarizing a deeper discussion Maksim and I had:

  • (paraphrasing) Here we deal with potentially different script public key kind, and it's wrong to assume they only are p2pk related
  • Maksim suggested that in an ideal world, we would allow end-user to finalize each inputs of a pskt according to their script kind (ex: with two inputs of different kinds: pskt.finalize([Enum.P2PK, Enum.OtherKind]). It has been identified this would represent a difficulty when the entry point is a pskb (list of pskt) as the API could be quite confusing (list of list of spk kind)
  • We ended up by agreeing that only allowing P2PK finalization and return Err in other cases is sufficient in an initial implementation

naming suggestion: finalize_p2pk

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NotInitialized,

#[error("Invalid ScriptClass, expected {0}, got {1}")]
InvalidScriptClassError(ScriptClass, ScriptClass),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Id say its unexpected rather than invalid

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


#[wasm_bindgen_test]
fn _test_finalize_and_extract_transaction() {
fn _test_finalize_p2pk() {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is the reason to start it with underscore?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm unsure why, but if i don't set it, i have clippy warnings about unused. I guess it has to do with targets.
If you know a better way, happy to make changes on it

.zed/debug.json Outdated
Comment on lines 1 to 82
// Project-local debug tasks
//
// For more documentation on how to configure debug tasks,
// see: https://zed.dev/docs/debugger
[
// Debug the `vccv2` example (cargo debug build)
{
"adapter": "CodeLLDB",
"label": "wrpc vccv2 (debug)",
"build": {
"label": "cargo build (wrpc example vccv2, debug)",
"command": "cargo",
"args": [
"build",
"--manifest-path",
"rpc/wrpc/examples/vcc_v2/Cargo.toml"
],
"env": {
"RUSTC_TOOLCHAIN": "C:\\Users\\Izio\\.rustup\\toolchains\\stable-x86_64-pc-windows-msvc"
},
"cwd": "D:\\Dev\\kaspa\\rusty-kaspa",
"use_new_terminal": false,
"allow_concurrent_runs": false,
"reveal": "always",
"reveal_target": "dock",
"hide": "never",
"tags": [],
"shell": "system",
"show_summary": false,
"show_command": false
},
// On Windows keep .exe; on Unix drop the .exe
"program": "D:\\Dev\\kaspa\\rusty-kaspa\\target\\debug\\examples\\vccv2.exe",
"args": [],
"cwd": "D:\\Dev\\kaspa\\rusty-kaspa",
"env": {
"RUST_BACKTRACE": "full",
"RUST_LOG": "info"
},
"sourceLanguages": ["rust"]
},
// Debug the `vccv2` example (cargo debug build)
{
"adapter": "CodeLLDB",
"label": "kaspad (debug)",
"request": "launch",
"build": {
"label": "cargo build (kaspad)",
"command": "cargo",
"args": ["build", "--manifest-path", "kaspad/Cargo.toml"],
"env": {
"RUSTC_TOOLCHAIN": "C:\\Users\\Izio\\.rustup\\toolchains\\stable-x86_64-pc-windows-msvc"
},
"cwd": "D:\\Dev\\kaspa\\rusty-kaspa",
"use_new_terminal": false,
"allow_concurrent_runs": false,
"reveal": "always",
"reveal_target": "dock",
"hide": "never",
"tags": [],
"shell": "system",
"show_summary": false,
"show_command": false
},

"program": "D:\\Dev\\kaspa\\rusty-kaspa\\target\\debug\\kaspad.exe",

// IMPORTANT: args must be an array, and backslashes escaped
"args": [
"--appdir=F:\\\\tmp-2",
"--utxoindex",
"--rpclisten-borsh=0.0.0.0"
],

"cwd": "D:\\Dev\\kaspa\\rusty-kaspa",
"env": {
"RUST_BACKTRACE": "full",
"RUST_LOG": "info"
},
"sourceLanguages": ["rust"]
}
]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

im not sure the file should be commited

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes totally a mistake 👍

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment on lines +430 to +487
/// Sign the PSKT with the provided private keys.
/// The method will find the inputs corresponding to the private keys and sign them.
/// This method performs partial signing, so if no private keys are provided, it will
/// return an unmodified PSKT.
#[wasm_bindgen]
pub fn sign(&self, private_keys: PrivateKeyArrayT, network_type: &NetworkTypeT) -> Result<PSKT> {
let prefix: Prefix = network_type.try_into()?;

let private_keys: Vec<PrivateKey> =
private_keys.try_into().map_err(|e| Error::Custom(format!("Invalid private keys: {:?}", e)))?;

let mut key_map: HashMap<Address, PrivateKey> = HashMap::new();
for pk in private_keys {
// TODO: address this unwrap
key_map.insert(pk.to_address(network_type).unwrap(), pk);
}

let signer_pskt: Native<Signer> = match self.take() {
State::NoOp(inner) => inner.ok_or(Error::NotInitialized)?.into(),
State::Creator(pskt) => pskt.constructor().signer(),
State::Constructor(pskt) => pskt.signer(),
State::Updater(pskt) => pskt.signer(),
State::Signer(pskt) => pskt,
State::Combiner(pskt) => pskt.signer(),
state => return Err(Error::state(state))?,
};

let reused_values = SigHashReusedValuesUnsync::new();
let signed_pskt = signer_pskt.pass_signature_sync::<_, Error>(|tx, sighash| {
let signatures = tx
.as_verifiable()
.populated_inputs()
.enumerate()
.filter_map(|(idx, (_, utxo_entry))| {
extract_script_pub_key_address(&utxo_entry.script_public_key, prefix).ok().and_then(|address| {
key_map.get(&address).map(|private_key| {
let hash = calc_schnorr_signature_hash(&tx.as_verifiable(), idx, sighash[idx], &reused_values);
let msg =
Message::from_digest_slice(hash.as_bytes().as_slice()).map_err(|e| Error::Custom(e.to_string()))?;

let keypair = secp256k1::Keypair::from_seckey_slice(secp256k1::SECP256K1, &private_key.secret_bytes())
.map_err(|e| Error::Custom(e.to_string()))?;

Ok(SignInputOk {
signature: Signature::Schnorr(keypair.sign_schnorr(msg)),
pub_key: keypair.public_key(),
key_source: None,
})
})
})
})
.collect::<Result<Vec<_>>>()?;

Ok(signatures)
})?;

self.replace(State::Signer(signed_pskt))
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. utxo can belong to p2sh. it doesnt mean we want to skip signing. user must decide if it want sign or not
  2. its possible to sign with ecdsa too
  3. you can sign the same input with multiple keys which is useful for multisig like scripts.

only finalizer is responsible to form signature script. its not an issue if it has more signatures than needed.

  1. secp256k1 private key can be used for both ecdsa or schnorr, so caller must decide what signatures it want to pass. and for what input

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ack on 2, 3 and 4.

Could you please detail 1. A bit more, I'm unsure how to proceed with it. Should the function handle all of the ScriptClass possible?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nah. The caller is who decides what signature it wants to pass. Like for multisig each participant signs the same utxo. Basically if they pass wrong signature finalized will fail. You can either add helper method that only works for p2pk or just assume caller reads pskt/psbt fields and signs with required key. They can look at redeem script and/or utxo. Basically it's passing signature to the map that may be reused by finaliser

Comment on lines +532 to +538
if input.partial_sigs.len() != 1 {
return Err(format!(
"finalize_p2pk error: input for outpoint {} must have exactly one signature, but has {}",
input.previous_outpoint,
input.partial_sigs.len()
));
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

im not sure it must be error. you can extract what sig you need and leave others

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't consider multi sig for this implementation indeed, is it what you meant? That there are cases where we could have more than 1 signatue?

Comment on lines +559 to +564
let mutable_transaction = finalized_pskt
.extractor()?
.extract_tx(&network_type.into())
.map_err(|e| Error::custom(format!("Failed to extract transaction: {e}")))?;

Ok(mutable_transaction.tx.into())
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

finalizer can only build tx id and signature scripts.

extractor may perform simulation(by default) and calculates mass, also metable tx has tx, mass and utxo entries. pskt interface returns all of them to caller. if you assume that caller doesnt need them - u can extract and return tx as you r doing now

/// This method performs partial signing, so if no private keys are provided, it will
/// return an unmodified PSKT.
#[wasm_bindgen]
pub fn sign(&self, private_keys: PrivateKeyArrayT, network_type: &NetworkTypeT) -> Result<PSKT> {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also for multisig or other purposes Input.bip32_derivations and global.xpubs may be checked to define what keys must be used. but i think its unrelevant to sign fn that must allow caller to pass any signature to any input

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants