Skip to content

[FEATURE] Add stacks chain support#115

Open
tony1908 wants to merge 12 commits intoopen-wallet-standard:mainfrom
tony1908:feat/add-stacks-chain
Open

[FEATURE] Add stacks chain support#115
tony1908 wants to merge 12 commits intoopen-wallet-standard:mainfrom
tony1908:feat/add-stacks-chain

Conversation

@tony1908
Copy link
Copy Markdown

@tony1908 tony1908 commented Mar 25, 2026

What

Add Stacks (STX) as a supported chain family: wallet creation, address derivation (c32check), transaction signing (SHA-512/256 presign hash, VRS signatures), message signing (Stacks prefix), and transaction broadcast via Hiro API.

Why

Stacks is a Bitcoin L2 with growing adoption for smart contracts and DeFi. Adding native support enables OWS wallets to derive Stacks addresses from the same mnemonic as all other chains, and supports x402 payment signing workflows on Stacks.

Testing

Manual testing

  1. Build
    cargo build --release --bin ows

  2. Create a wallet (derives addresses for all chains including Stacks)
    ./target/release/ows wallet create --name my-wallet
    → stacks:1 → SP...

  3. Sign a message
    ./target/release/ows sign message --wallet my-wallet --chain stacks --message "hello stacks"
    → 65-byte VRS signature hex

  4. Sign a transaction (requires an unsigned tx hex built externally)
    Use @stacks/transactions to build the unsigned tx:

    import { makeUnsignedSTXTokenTransfer, AnchorMode } from '@stacks/transactions';
    import { StacksMainnet } from '@stacks/network';

    const tx = await makeUnsignedSTXTokenTransfer({
    recipient: 'SP...',
    amount: BigInt(10),
    publicKey: '',
    network: new StacksMainnet(),
    memo: 'test',
    anchorMode: AnchorMode.Any,
    });
    console.log(Buffer.from(tx.serialize()).toString('hex'));

Then sign and broadcast with OWS:
./target/release/ows sign send-tx --wallet my-wallet --chain stacks --tx <unsigned_tx_hex>
→ broadcasts to https://api.hiro.so/v2/transactions, returns txid

Notes

  • No new dependencies — Stacks uses secp256k1 + SHA-256 + SHA-512/256 + RIPEMD-160, all already in ows-signer
  • c32check encoding implemented from scratch (~60 lines) following https://github.com/stacks-network/c32check verified against the official @stacks/transactions JS library
  • Coin type 5757 per SLIP-44 registration for Stacks
  • Stacks key convention: 32-byte private key → uncompressed pubkey, 33-byte key (with 0x01 suffix) →compressed
    pubkey — matches @stacks/transactions behavior
  • Transaction signing uses the Stacks presign hash protocol: SHA-512/256(SHA-512/256(cleared_tx) || auth_type || fee
    || nonce) — matches @stacks/transactions exactly
  • Signature format is VRS (recovery_id || r || s) — 65 bytes
  • encode_signed_transaction injects VRS at byte offset 44 (standard single-sig P2PKH layout)
  • broadcast_stacks POSTs raw bytes to Hiro /v2/transactions, default RPC set to https://api.hiro.so
  • Only mainnet (stacks:1) registered as a known chain. Testnet (StacksSigner::testnet(), version byte 26) is implemented but not wired — follow-up if needed

@tony1908 tony1908 requested a review from njdawn as a code owner March 25, 2026 01:19
@vercel
Copy link
Copy Markdown

vercel bot commented Mar 25, 2026

@tony1908 is attempting to deploy a commit to the MoonPay Team on Vercel.

A member of the Team first needs to authorize it.

Copy link
Copy Markdown
Contributor

@njdawn njdawn left a comment

Choose a reason for hiding this comment

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

thanks! could you verify these changes? testing locally resulted in failures.

I validated this against the repo and reproduced a concrete interoperability failure.

In the checked-out PR branch, I generated a signature with ows/crates/ows-signer/src/chains/stacks.rs and verified it with standard Stacks tooling (@stacks/encryption / verifyMessageSignatureRsv). The result was verified=false for a simple sign_message("hello stacks") round-trip.
The code path hashes the message with double SHA-256 before signing, which does not match standard Stacks message-signing verification expectations.

@tony1908 tony1908 requested a review from njdawn March 27, 2026 21:18
Copy link
Copy Markdown
Contributor

@njdawn njdawn left a comment

Choose a reason for hiding this comment

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

I tested this branch locally and the happy-path Stacks tests pass, but I found a concrete correctness issue that makes the transaction-signing support too broad as currently implemented.

I generated a real unsigned 1-of-2 multisig STX transfer with @stacks/transactions, then signed the same unsigned tx two ways:

  1. With the canonical Stacks JS library (makeSTXTokenTransfer(... signerKeys: [priv1]))
  2. With this PR's actual CLI path (cargo run -p ows-cli -- sign tx --chain stacks --tx <unsigned_multisig_hex>)

The signatures do not match.

  • Official Stacks JS signature:
    00a3cdc4cfef25ee355bb0d24d31259e02020079832c859d2d6bb866443babd5d63f5a22daa9852582794311a55a89ec4e141529b9f2ba21a84c16926a92ce051a
  • OWS PR #115 signature:
    017b7ac977ac2e892e7c7143b73d66ff01e6f01e8321c8c7558ea9892314ecbf173d6afffea5237971bfe5302b63f17eb60f3bd4d0ff34754f08e43210248fa54e

The root cause appears to be the fixed single-sig layout assumptions in stacks.rs:

  • STACKS_SIG_OFFSET is hardcoded to 44
  • sign_transaction() clears bytes 44..109
  • encode_signed_transaction() injects the signature at bytes 44..109

That works for standard single-sig P2PKH only, but for unsigned multisig the auth section after byte 44 is dynamic. In the concrete tx I generated, the payload starts at byte 50, so clearing 44..109 zeros part of the payload and produces the wrong presign hash.

So I think this PR is mergeable only if one of these happens:

  • It explicitly validates and rejects anything except the supported single-sig transaction shape, or
  • It implements the real Stacks auth parsing/signing path for multisig/sponsored layouts instead of assuming a fixed offset.

Validate auth_type (0x04) and hash_mode (0x00) before signing or encoding Stacks transactions. The fixed byte offsets used for presign hashing and signature injection are only valid for standard P2PKH                             single-sig; multisig and sponsored layouts have different auth structures and would produce wrong signatures silently.

Addresses review feedback on PR open-wallet-standard#115.
@ECBSJ
Copy link
Copy Markdown

ECBSJ commented Apr 3, 2026

@tony1908 how should one handle the publicKey property when constructing a makeUnsignedSTXTokenTransfer transaction? In your PR description, you have it as an empty string but it's a required property.

Copy link
Copy Markdown
Contributor

@njdawn njdawn left a comment

Choose a reason for hiding this comment

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

High — ows/crates/ows-core/src/config.rs: default_rpc() adds a Stacks endpoint, but test_load_or_default_nonexistent still expects the old RPC count. The branch currently fails this existing test and should not merge until it is updated.

@tony1908 tony1908 requested a review from njdawn April 3, 2026 18:25
@tony1908
Copy link
Copy Markdown
Author

tony1908 commented Apr 3, 2026

The publicKey must be the sender's actual public key as a hex string — it cannot be empty. It controls two things in the unsigned transaction:

  1. The signer hash160 — the node uses this to look up the sender's balance
  2. The key_encoding byte — 33 bytes (66 hex chars) → compressed (0x00), 65 bytes (130 hex chars) → uncompressed (0x01)

This is critical because compressed and uncompressed keys derive different Stacks addresses. If the wallet derived the address from an uncompressed key, you must pass the uncompressed pubkey — otherwise the transaction will be rejected with NotEnoughFunds because the node checks the balance of the wrong address.

// Compressed (33 bytes) → derives a different SP address
publicKey: '03ea909707c97c498a...'

// Uncompressed (65 bytes) → derives the correct SP address
publicKey: '04ea909707c97c498a...5fff641c3d704895cd...'

In OWS, the default Stacks key derivation uses uncompressed public keys (32-byte private key without the 0x01 suffix), so the uncompressed form is the one that matches the wallet address. The public key can be derived from the private key or recovered from a message signature.

@tony1908
Copy link
Copy Markdown
Author

tony1908 commented Apr 3, 2026

High — ows/crates/ows-core/src/config.rs: default_rpc() adds a Stacks endpoint, but test_load_or_default_nonexistent still expects the old RPC count. The branch currently fails this existing test and should not merge until it is updated.

my bad @njdawn missed that test, it is ready now

@ECBSJ
Copy link
Copy Markdown

ECBSJ commented Apr 4, 2026

@tony1908 makes sense, just wanted to confirm :)

so in essence, the agent will have to take an extra step to derive the public key before transaction construction. i dont think there is a CLI command to extract the public key, right? so deriving it from a message signature might be the only way?

@tony1908
Copy link
Copy Markdown
Author

tony1908 commented Apr 4, 2026

Correct — there's no CLI command today to extract the public key directly. The current workarounds are:

  1. Recover from a signature — sign any message with ows sign message --chain stacks --wallet <name> --message "test" --json, then recover the pubkey from the VRS signature + known message hash using secp256k1 recovery
  2. Derive from the mnemonic — export the mnemonic with ows wallet export, then derive the Stacks key externally using the BIP-44 path m/44'/5757'/0'/0/0

Neither is great for an agent workflow. A --json flag on ows wallet list that includes the public key per account (or a dedicated ows wallet pubkey --chain stacks --wallet <name>) would be the clean solution. That's a gap worth adding — I'll open an issue for it.

Copy link
Copy Markdown
Contributor

@njdawn njdawn left a comment

Choose a reason for hiding this comment

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

after merge conflicts fixed, ready to merge

@tony1908
Copy link
Copy Markdown
Author

tony1908 commented Apr 8, 2026

@njdawn conflicts fixed, seems like vercel run need auth

Copy link
Copy Markdown
Contributor

@njdawn njdawn left a comment

Choose a reason for hiding this comment

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

ci is failing.

@tony1908
Copy link
Copy Markdown
Author

tony1908 commented Apr 9, 2026

@njdawn there was a conflict with the new chains but it is fixed now, all tests running correctly

@tony1908 tony1908 requested a review from njdawn April 10, 2026 05:39
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.

3 participants