pusaka-mcp 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,48 @@
1
+ {
2
+ "contract": "pusaka-lend.testnet",
3
+ "stable": "3e2210e1184b45b64c8a434c0a7e7b23cc04ea7eb7a6c3c32520d03d4afcb8af",
4
+ "wasm_sha256": "ac5e97fc6e9d70c7e39a2712eac83e3b87613df2804b14373fb3d6fc81fe4454",
5
+ "pusd_wasm_sha256": "d9b45258150e64a21f712c34fc68d7d9ff0b7b969ccdf7b3a02f48b0eecc9fbf",
6
+ "build": "NEP-330 reproducible: cargo near build reproducible-wasm in sourcescan/cargo-near:0.21.1-rust-1.96.1 (CI job lend-wasm; lend from run 43369725784 at commit 081fdd7, with reset; pUSD from run 43366666530 at d6830cd); contract_source_metadata names each",
7
+ "config": {
8
+ "nfts": [
9
+ "pusaka-v2.testnet",
10
+ "pusaka-btc.testnet"
11
+ ],
12
+ "reporters": [
13
+ "pusaka-relayer.testnet (mempool.space)",
14
+ "pusaka-admin.testnet (CoinGecko)",
15
+ "pusaka-seller.testnet (Rhea nBTC)"
16
+ ],
17
+ "quorum": 2,
18
+ "max_age_ms": 7200000,
19
+ "max_ltv_bps": 7950,
20
+ "liq_ltv_bps": 8000,
21
+ "liq_price_bps": 9500,
22
+ "rate_bps": 800
23
+ },
24
+ "live_13_nov": {
25
+ "deploy": "9PffaZb37Dq2QtSB6UncHsDAY7vEZF9u1dKd5dfhBZJi",
26
+ "init": "6hwXNT6LqYJndj6BhNAs4r7PGESpGwbpV8R9gBnTXsXZ",
27
+ "first_prices": [
28
+ "6zmxS7uQtdBzKckDpH7ndP9YUeYcD27HRQjGDghMuX4V",
29
+ "8fGBxYCRGGJ5kyBZshdXtsPnEq8bNuWE75LvUqq2RasB",
30
+ "8orwyLDFahAoNrQnBefrdRyZRhGHvWTTBRU9TGMRcq48"
31
+ ],
32
+ "supply_200": "CRMTUJ8oGdGoz7qUnzBfMqE3DDn2UmZTvrXsuapqiqci",
33
+ "borrow_1_against_classic_11": "9vDbh8MNhdGggZUyLhVVhfqxAeqGK8H1Bi1V9iDNKNT8",
34
+ "repay": "95nnpFVb1ztDTczCYrAG2X7XjQNeBrEoM9n5EvUEzowA",
35
+ "borrow_over_50pct_refused_nft_returned": "H1oUW2TpMczMMyCYraHHJvFpJbXsVNCrc6ZqqJ9WrHvM",
36
+ "redeploy_reproducible": [
37
+ "5t6nhaa8MvrKdZNrwApPQdxECguMG7JpxvDL1EwGQUDm",
38
+ "KNxFGJvjmTTVeXJYHgmRRWssRjfW48ugJkqsWRfkjnt"
39
+ ],
40
+ "redeploy_with_reset": "91Bpxx6DbGxgQdkVxgzoM65Vj2dPziPC6Prgny2tkoUY",
41
+ "pusd_withdrawn": "FqQMqgnMexzjoQCpGUvb2ZbN3mcfvF1pKGabYy7kkE6H",
42
+ "reset_to_circle_usdc": "7tEDaihu6zeV29JuzbyVvwGVv6ubuETUvsAAMZzAmWsh",
43
+ "usdc_supply_10": "BxcQTFLpFxhs8AT2nSnLcFoCMf9A1aV1nKusju7esrcw",
44
+ "usdc_borrow_at_79_49pct_against_v2_1": "ERLuF8nfwMTPBjywXRNxZpFHc8hLrbgfnk2Lj4xWTxX1"
45
+ },
46
+ "keys": "pusaka-lend.testnet and pusaka-usd.testnet still hold a full-access key; deleted with the others at the final step (needs the owner's 2x OK)",
47
+ "stable_note": "Circle's testnet USDC on NEAR since the reset of 13 Nov; pusaka-usd.testnet (pUSD) before"
48
+ }
@@ -1,110 +1,112 @@
1
- {
2
- "network": "testnet",
3
- "contract": "pusaka-v2.testnet",
4
- "note": "v2 runs beside v1 (pusaka-btc.testnet, renounced). Same contract plus F5/F6/F7 (contract/src/v2.rs); upgradeable through the DAO and a timelock.",
5
- "code": {
6
- "wasm_sha256": "6eb67445d7d666ba96cfc45bd4d15f9d296c031730d9ff61d3f19d442b2dc3e8",
7
- "build": "cargo near build non-reproducible-wasm --no-abi (cargo-near 0.22.0)",
8
- "deployed_tx": "CAtnMCghw4fphNTKA8NGyPg8aSvz3votn6W142o4oDNg",
9
- "init_tx": "44LTTzsKj6gBbMxMk4fdqpNMmVFcsFLXiGYv7E7KVBgk",
10
- "upgrade_admin_tx": "7JmL7h33raNUANbExFR6QB9AofxWqCbQyDaiEQ2mUqXQ",
11
- "upgrades": "DAO proposal -> propose_upgrade(sha256) -> 10-minute timelock -> apply_upgrade",
12
- "history": [
13
- {
14
- "sha256": "ab6d5dcf9caffbe5e5085d680667a8dcb1bbadc01983c5f4b37fb16ad4e2a29c",
15
- "tx": "CAtnMCghw4fphNTKA8NGyPg8aSvz3votn6W142o4oDNg",
16
- "how": "initial deploy"
17
- },
18
- {
19
- "sha256": "4dd2473b5fdbfb801a4cc15b8ca6202346b181464d3a7239985bcf3b45fab849",
20
- "tx": "C2Dgr5iW4wX41XfXFMagCgn3Jzj5jE1ji4RfYKaWC1v3",
21
- "how": "DAO proposal 8 + timelock: security review fixes"
22
- },
23
- {
24
- "sha256": "d6c183da958efd3fca3547b81d38c6bd58e1fd6d2d2da0196cdd05eb4e788741",
25
- "tx": "DbaTmJfisAmDZWUd4NeUZfG4LvchNLUP6FRbLeXDMzv5",
26
- "how": "DAO proposal 9 + timelock: account_call callback frees only its own lock"
27
- },
28
- {
29
- "sha256": "86180591a701e193045ebdec5eda3050ff601a7749f79e8fdf1d75e5db777d12",
30
- "tx": "GLiiH86JNick5MPpyh4sHudQJPsQDuKQABvnUM2pS92c",
31
- "how": "DAO proposal 10 + timelock: F8 agent budget (set_agent, agent_send, agent_of)"
32
- },
33
- {
34
- "sha256": "c786e1e64c45dca6821a4abdd991f2978b66f5dd1659773560fd2a5112d4f838",
35
- "tx": "Eqimmrz6sg31CBsCr1Y4wjut96y3yHawx71UZ3uNhogN",
36
- "how": "DAO proposal 11 + timelock: F9 sign in as the NFT (EIP-4361); V2-9 account_call refuses lasting grants"
37
- },
38
- {
39
- "sha256": "0c66a94fa6fc33c10924fcf5034db4add3077c1c779f1faadb80d85daeb4eaf6",
40
- "tx": "FcFuQZVqV7zKuSJvBsUwLcFcXxE2YGZ6J9rhFa9gAp3q",
41
- "how": "DAO proposal 12 + timelock: V2-10 a sign-in can't outlive a sale (no sign-in while listed; no listing or approval while one is valid)"
42
- },
43
- {
44
- "sha256": "8c66134f3d2e0cf4ee985b67f3ae46d1ca283379a1736446fdb45a08696f573b",
45
- "tx": "3bk7HsQmkjDRZdgtkMGbR7oue4PYgMGJ2p9opoKRMu4i",
46
- "how": "DAO proposal 13 + timelock: V2-10 the sign-in record survives a transfer; nft_transfer_call and loans gated too"
47
- },
48
- {
49
- "sha256": "d3972d98c37ea0d51e654257a2055860229a3c5d42895690a6d06f46a8775edf",
50
- "tx": "8ufh43d13htBmG1MzfCfRzQ3SHYGFeGgLU79uP6fyGi4",
51
- "how": "DAO proposal 14 + timelock: V2-11 the NFT's account can only nft_transfer a Pusaka it holds (adversarial review); auth denylist; agent budget saturating and cleared on nft_transfer_call; no transfer into a token's own account; sign-in gate on gift"
52
- },
53
- {
54
- "sha256": "5f9bb600d542ce0692488962682cc9b22fc23f0c05b90fd1900f649fc93124c5",
55
- "tx": "HegurWNt3j2z2mGBoZpwu3tBeYPNKDPXo7q1KC81RQh9",
56
- "how": "DAO proposal 15 + timelock: account_call takes an allowlist of asset-moving methods for every receiver; no nft_transfer of a signed-in Pusaka into a token account"
57
- },
58
- {
59
- "sha256": "56895764fa1809efcdc6f7dbe2cc3afb9f0abd1131811239130fd5706f0c2df6",
60
- "tx": "3DovJDZiNTC9Ydt27dTkJzQ9BH6JdWdZ9tb3ksPRpdDk",
61
- "how": "DAO proposal 16 + timelock: F10 BIP-322 proof of the NFT's Bitcoin address, F11 renter (ERC-4907 user) who can sign in, F12 buy with native Bitcoin settled by the light client, F13 reserves view; heir claim waits for a reserved Bitcoin buyer"
62
- },
63
- {
64
- "sha256": "ddf231e3403060d96277249b798b24e7d8145a3517edb17303c6f40ff84fcaf4",
65
- "tx": "AtRLSQVrss2iXNe78KxiZQfxgy1k9MYXXey3AzjHujYM",
66
- "how": "DAO proposal 17 + timelock: F12 reservation nonce never repeats per token (no payment replay after a resale); a paid buyer keeps the sale through a proof grace period"
67
- },
68
- {
69
- "sha256": "fec63dcf6d6e952e01b4950c5f8f4f53a2baca5ed37028d40817f812c1a733d6",
70
- "tx": "5PTEL1TRmP64pzBLTx9wqLcjXbNsWPYkRQSuMJVzpzNk",
71
- "how": "DAO proposal 18 + timelock: a Bitcoin txid that bought a Pusaka is spent for good; the hand-over checks the seller still holds the NFT"
72
- },
73
- {
74
- "sha256": "9fa6298302ee56ee6b235c44a6b35d9085e379ccd3aa8fce46e6aa922fbb753b",
75
- "tx": "7C9PNi3DqjQUMjfDLk6s6qiV3jbmARq1wSNJAEnze8SG",
76
- "how": "DAO proposal 19 + timelock: F14 vault with clawback, F15 covered calls"
77
- },
78
- {
79
- "sha256": "f587ea2f414a0365cd33ffaa41ef87d9f5c1e4ea9f26e1ba62dbd8a0973cb09a",
80
- "tx": "9ndbzcAbw4LPjcQi93W1RU9yH1McrVGKPvez8UGyos1U",
81
- "how": "DAO proposal 20 + timelock: F16 a call whose strike is paid in native Bitcoin, proven by the light client"
82
- },
83
- {
84
- "sha256": "8cfe4fbdded358346631b72bf6963e3561475d152848a9ed32642d8ed8d1a13c",
85
- "tx": "FcXok8rJW4t2eSGBTL2QVCNr7tHMTKM9GWMfu7Gk9qZg",
86
- "how": "DAO proposal 21 + timelock: F16 in-flight proofs counted (a bogus proof can't block the genuine one); no OP_RETURN shown after the time to pay"
87
- },
88
- {
89
- "sha256": "6eb67445d7d666ba96cfc45bd4d15f9d296c031730d9ff61d3f19d442b2dc3e8",
90
- "tx": "3AH3PFGUgd9sUDLQpcZ9ZkJQp6zCmM2DFhXKDPPFBiRP",
91
- "how": "DAO proposal 22 + timelock: F16 proofs need the reservation window; the call can't return to its writer; a lost callback stops counting after an hour"
92
- }
93
- ]
94
- },
95
- "token_account": {
96
- "source": "token-account/",
97
- "wasm_sha256": "2be45c22d907ed2f154eb0d991ff8e6155a04201fbb41421c44947d6986bd2ef",
98
- "global_contract_hash_base58": "3xLVdgZA1XcRXC1wg1naiuNmAZKNrJuza56j9Jg7Ea6a",
99
- "published_tx": "7nerp9Z3So3c3UQgXSvFxyCibQVXmjMyRCEHDhHMbVZo"
100
- },
101
- "accounts": {
102
- "upgrade_dao": "pusaka-council.sputnikv2.testnet"
103
- },
104
- "uses": {
105
- "mpc": "v1.signer-prod.testnet",
106
- "btc_light_client": "btc-client-v4.testnet",
107
- "social": "v1.social08.testnet"
108
- },
109
- "explorer": "https://testnet.near.rocks"
110
- }
1
+ {
2
+ "network": "testnet",
3
+ "contract": "pusaka-v2.testnet",
4
+ "note": "v2 runs beside v1 (pusaka-btc.testnet, renounced). Same contract plus F5/F6/F7 (contract/src/v2.rs); upgradeable through the DAO and a timelock.",
5
+ "code": {
6
+ "wasm_sha256": "f6aebe6fe0f79c4f0698cdb5815dae6e0d6b73e5e1efc37f28bc9fbab35a9560",
7
+ "build": "cargo near build reproducible-wasm (CI job reproducible-wasm, image sourcescan/cargo-near:0.21.1-rust-1.96.1, NEP-330 metadata on-chain)",
8
+ "deployed_tx": "AuRjbxKHZaisLf7CotKJJFTMsbZv6SNH36u4G5QDQoXq",
9
+ "init_tx": "44LTTzsKj6gBbMxMk4fdqpNMmVFcsFLXiGYv7E7KVBgk",
10
+ "upgrade_admin_tx": "7JmL7h33raNUANbExFR6QB9AofxWqCbQyDaiEQ2mUqXQ",
11
+ "upgrades": "DAO proposal -> propose_upgrade(sha256) -> 10-minute timelock -> apply_upgrade",
12
+ "history": [
13
+ {
14
+ "sha256": "ab6d5dcf9caffbe5e5085d680667a8dcb1bbadc01983c5f4b37fb16ad4e2a29c",
15
+ "tx": "CAtnMCghw4fphNTKA8NGyPg8aSvz3votn6W142o4oDNg",
16
+ "how": "initial deploy"
17
+ },
18
+ {
19
+ "sha256": "4dd2473b5fdbfb801a4cc15b8ca6202346b181464d3a7239985bcf3b45fab849",
20
+ "tx": "C2Dgr5iW4wX41XfXFMagCgn3Jzj5jE1ji4RfYKaWC1v3",
21
+ "how": "DAO proposal 8 + timelock: security review fixes"
22
+ },
23
+ {
24
+ "sha256": "d6c183da958efd3fca3547b81d38c6bd58e1fd6d2d2da0196cdd05eb4e788741",
25
+ "tx": "DbaTmJfisAmDZWUd4NeUZfG4LvchNLUP6FRbLeXDMzv5",
26
+ "how": "DAO proposal 9 + timelock: account_call callback frees only its own lock"
27
+ },
28
+ {
29
+ "sha256": "86180591a701e193045ebdec5eda3050ff601a7749f79e8fdf1d75e5db777d12",
30
+ "tx": "GLiiH86JNick5MPpyh4sHudQJPsQDuKQABvnUM2pS92c",
31
+ "how": "DAO proposal 10 + timelock: F8 agent budget (set_agent, agent_send, agent_of)"
32
+ },
33
+ {
34
+ "sha256": "c786e1e64c45dca6821a4abdd991f2978b66f5dd1659773560fd2a5112d4f838",
35
+ "tx": "Eqimmrz6sg31CBsCr1Y4wjut96y3yHawx71UZ3uNhogN",
36
+ "how": "DAO proposal 11 + timelock: F9 sign in as the NFT (EIP-4361); V2-9 account_call refuses lasting grants"
37
+ },
38
+ {
39
+ "sha256": "0c66a94fa6fc33c10924fcf5034db4add3077c1c779f1faadb80d85daeb4eaf6",
40
+ "tx": "FcFuQZVqV7zKuSJvBsUwLcFcXxE2YGZ6J9rhFa9gAp3q",
41
+ "how": "DAO proposal 12 + timelock: V2-10 a sign-in can't outlive a sale (no sign-in while listed; no listing or approval while one is valid)"
42
+ },
43
+ {
44
+ "sha256": "8c66134f3d2e0cf4ee985b67f3ae46d1ca283379a1736446fdb45a08696f573b",
45
+ "tx": "3bk7HsQmkjDRZdgtkMGbR7oue4PYgMGJ2p9opoKRMu4i",
46
+ "how": "DAO proposal 13 + timelock: V2-10 the sign-in record survives a transfer; nft_transfer_call and loans gated too"
47
+ },
48
+ {
49
+ "sha256": "d3972d98c37ea0d51e654257a2055860229a3c5d42895690a6d06f46a8775edf",
50
+ "tx": "8ufh43d13htBmG1MzfCfRzQ3SHYGFeGgLU79uP6fyGi4",
51
+ "how": "DAO proposal 14 + timelock: V2-11 the NFT's account can only nft_transfer a Pusaka it holds (adversarial review); auth denylist; agent budget saturating and cleared on nft_transfer_call; no transfer into a token's own account; sign-in gate on gift"
52
+ },
53
+ {
54
+ "sha256": "5f9bb600d542ce0692488962682cc9b22fc23f0c05b90fd1900f649fc93124c5",
55
+ "tx": "HegurWNt3j2z2mGBoZpwu3tBeYPNKDPXo7q1KC81RQh9",
56
+ "how": "DAO proposal 15 + timelock: account_call takes an allowlist of asset-moving methods for every receiver; no nft_transfer of a signed-in Pusaka into a token account"
57
+ },
58
+ {
59
+ "sha256": "56895764fa1809efcdc6f7dbe2cc3afb9f0abd1131811239130fd5706f0c2df6",
60
+ "tx": "3DovJDZiNTC9Ydt27dTkJzQ9BH6JdWdZ9tb3ksPRpdDk",
61
+ "how": "DAO proposal 16 + timelock: F10 BIP-322 proof of the NFT's Bitcoin address, F11 renter (ERC-4907 user) who can sign in, F12 buy with native Bitcoin settled by the light client, F13 reserves view; heir claim waits for a reserved Bitcoin buyer"
62
+ },
63
+ {
64
+ "sha256": "ddf231e3403060d96277249b798b24e7d8145a3517edb17303c6f40ff84fcaf4",
65
+ "tx": "AtRLSQVrss2iXNe78KxiZQfxgy1k9MYXXey3AzjHujYM",
66
+ "how": "DAO proposal 17 + timelock: F12 reservation nonce never repeats per token (no payment replay after a resale); a paid buyer keeps the sale through a proof grace period"
67
+ },
68
+ {
69
+ "sha256": "fec63dcf6d6e952e01b4950c5f8f4f53a2baca5ed37028d40817f812c1a733d6",
70
+ "tx": "5PTEL1TRmP64pzBLTx9wqLcjXbNsWPYkRQSuMJVzpzNk",
71
+ "how": "DAO proposal 18 + timelock: a Bitcoin txid that bought a Pusaka is spent for good; the hand-over checks the seller still holds the NFT"
72
+ },
73
+ {
74
+ "sha256": "9fa6298302ee56ee6b235c44a6b35d9085e379ccd3aa8fce46e6aa922fbb753b",
75
+ "tx": "7C9PNi3DqjQUMjfDLk6s6qiV3jbmARq1wSNJAEnze8SG",
76
+ "how": "DAO proposal 19 + timelock: F14 vault with clawback, F15 covered calls"
77
+ },
78
+ {
79
+ "sha256": "f587ea2f414a0365cd33ffaa41ef87d9f5c1e4ea9f26e1ba62dbd8a0973cb09a",
80
+ "tx": "9ndbzcAbw4LPjcQi93W1RU9yH1McrVGKPvez8UGyos1U",
81
+ "how": "DAO proposal 20 + timelock: F16 a call whose strike is paid in native Bitcoin, proven by the light client"
82
+ },
83
+ {
84
+ "sha256": "8cfe4fbdded358346631b72bf6963e3561475d152848a9ed32642d8ed8d1a13c",
85
+ "tx": "FcXok8rJW4t2eSGBTL2QVCNr7tHMTKM9GWMfu7Gk9qZg",
86
+ "how": "DAO proposal 21 + timelock: F16 in-flight proofs counted (a bogus proof can't block the genuine one); no OP_RETURN shown after the time to pay"
87
+ },
88
+ {
89
+ "sha256": "6eb67445d7d666ba96cfc45bd4d15f9d296c031730d9ff61d3f19d442b2dc3e8",
90
+ "tx": "3AH3PFGUgd9sUDLQpcZ9ZkJQp6zCmM2DFhXKDPPFBiRP",
91
+ "how": "DAO proposal 22 + timelock: F16 proofs need the reservation window; the call can't return to its writer; a lost callback stops counting after an hour"
92
+ }
93
+ ],
94
+ "source_commit": "2993767",
95
+ "dao_proposal": 23
96
+ },
97
+ "token_account": {
98
+ "source": "token-account/",
99
+ "wasm_sha256": "2be45c22d907ed2f154eb0d991ff8e6155a04201fbb41421c44947d6986bd2ef",
100
+ "global_contract_hash_base58": "3xLVdgZA1XcRXC1wg1naiuNmAZKNrJuza56j9Jg7Ea6a",
101
+ "published_tx": "7nerp9Z3So3c3UQgXSvFxyCibQVXmjMyRCEHDhHMbVZo"
102
+ },
103
+ "accounts": {
104
+ "upgrade_dao": "pusaka-council.sputnikv2.testnet"
105
+ },
106
+ "uses": {
107
+ "mpc": "v1.signer-prod.testnet",
108
+ "btc_light_client": "btc-client-v4.testnet",
109
+ "social": "v1.social08.testnet"
110
+ },
111
+ "explorer": "https://testnet.near.rocks"
112
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pusaka-mcp",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "MCP server for Pusaka: NFTs on NEAR that each control a native Bitcoin address (Chain Signatures), with deposits proven by NEAR's Bitcoin light client. Buy, lend, borrow, inherit, with spend caps.",
5
5
  "mcpName": "io.github.PugarHuda/pusaka",
6
6
  "type": "module",
@@ -41,9 +41,10 @@
41
41
  "bitcoinjs-lib": "^7.0.0",
42
42
  "viem": "^2.57.14",
43
43
  "@fastnear/x402": "^2.5.0",
44
- "@x402/core": "~2.18.0",
44
+ "@x402/core": "~2.30.0",
45
45
  "@modelcontextprotocol/sdk": "^1.32.2",
46
46
  "near-api-js": "^7.4.0",
47
- "zod": "^4.6.7"
47
+ "zod": "^4.6.7",
48
+ "@x402/fetch": "~2.30.0"
48
49
  }
49
50
  }
package/shared/audit.mjs CHANGED
@@ -1,79 +1,79 @@
1
- // Re-checks one Pusaka against the chains themselves, with no server of ours in between: NEAR (the contract),
2
- // NEAR's MPC signer, NEAR's Bitcoin light client, and Bitcoin (mempool.space, Blockstream as fallback).
3
- // Used by the web app's "Check it yourself" and by scripts/verify.mjs for every token. Reads only.
4
- import { base58, base64 } from "@scure/base";
5
- import { CONTRACT, MPC, BTC_LIGHT_CLIENT, btcAddress, btcFetch, lightClientHeight, nearFetch } from "./btc.mjs";
6
-
7
- const enc = new TextEncoder(), dec = new TextDecoder();
8
- const hex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
9
-
10
- async function rpc(method, params) {
11
- const j = await (await nearFetch({ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }) })).json();
12
- if (j.error) throw new Error(JSON.stringify(j.error).slice(0, 200));
13
- return j.result;
14
- }
15
-
16
- /** A view call at final finality, straight to a NEAR RPC. */
17
- export async function viewFinal(method, args = {}, account_id = CONTRACT) {
18
- const r = await rpc("query", { request_type: "call_function", finality: "final", account_id, method_name: method, args_base64: base64.encode(enc.encode(JSON.stringify(args))) });
19
- if (!r.result) throw new Error(r.error ?? `${method} failed`);
20
- return JSON.parse(dec.decode(new Uint8Array(r.result)));
21
- }
22
-
23
- /** The compressed secp256k1 key NEAR's MPC derives for `path` when this contract asks. */
24
- export async function mpcKeyFor(path) {
25
- const raw = base58.decode((await viewFinal("derived_public_key", { path, predecessor: CONTRACT, domain_id: 0 }, MPC)).split(":")[1]);
26
- return (raw[63] % 2 ? "03" : "02") + hex(raw.slice(0, 32));
27
- }
28
-
29
- // A Bitcoin explorer that rate-limits isn't a wrong balance: retry with backoff, then report it as unreachable.
30
- async function btcTx(txid) {
31
- for (let i = 0; i < 4; i++) {
32
- const tx = await btcFetch(`/tx/${txid}`).then((r) => (r.ok ? r.json() : null)).catch(() => null);
33
- if (tx) return tx;
34
- await new Promise((ok) => setTimeout(ok, 1500 * 2 ** i));
35
- }
36
- return null;
37
- }
38
-
39
- /**
40
- * Every check for one token, in order. Each is { ok, what, detail }; `ok` is null when a source couldn't be
41
- * reached (not the same as wrong). `lc` (light-client height) can be passed in when auditing many tokens.
42
- */
43
- export async function auditToken(tokenId, { lc, info } = {}) {
44
- info ??= await viewFinal("token_info", { token_id: String(tokenId) });
45
- if (!info?.btc_pubkey) return [{ ok: false, what: "this Pusaka has a Bitcoin key", detail: "no key recorded yet" }];
46
- lc ??= await lightClientHeight();
47
- const checks = [];
48
- const mpcKey = await mpcKeyFor(info.btc_path);
49
- const addr = btcAddress(info.btc_pubkey);
50
- checks.push({ ok: mpcKey === info.btc_pubkey, what: "its Bitcoin key is the one NEAR's MPC derives for this contract",
51
- detail: `${MPC} derived_public_key("${info.btc_path}", ${CONTRACT}) → ${addr}` });
52
-
53
- const signed = new Set(info.signed_outpoints.map((o) => `${o.txid}:${o.vout}`));
54
- const sum = info.proven_utxos.filter((u) => !signed.has(`${u.txid}:${u.vout}`)).reduce((a, u) => a + Number(u.value), 0);
55
- checks.push({ ok: sum === Number(info.verified_sats), what: "verified_sats = proven deposits it never signed away",
56
- detail: `${info.proven_utxos.length} proven, ${signed.size} outpoint(s) ever signed: ${sum} = ${info.verified_sats} sats` });
57
-
58
- for (const u of info.proven_utxos) {
59
- const tx = await btcTx(u.txid);
60
- const what = `deposit ${u.txid.slice(0, 10)}…:${u.vout} is on Bitcoin, confirmed, ${Number(u.value)} sats to ${addr.slice(0, 10)}…`;
61
- if (!tx) { checks.push({ ok: null, what, detail: "couldn't reach mempool.space or Blockstream (rate limit? try again)", txid: u.txid }); continue; }
62
- const out = tx.vout?.[u.vout];
63
- const good = !!out && out.scriptpubkey_address === addr && out.value === Number(u.value) && !!tx.status?.confirmed && tx.status.block_height <= lc;
64
- checks.push({ ok: good, what, txid: u.txid,
65
- detail: good ? `block ${tx.status.block_height}, light client ${BTC_LIGHT_CLIENT} at ${lc}` : `found ${out ? `${out.value} sats to ${out.scriptpubkey_address}` : "no such output"}, ${tx.status?.confirmed ? `block ${tx.status.block_height}` : "unconfirmed"}` });
66
- }
67
- return checks;
68
- }
69
-
70
- /** The contract-level facts behind every token: no access key, code final. */
71
- export async function auditContract() {
72
- const keys = (await rpc("query", { request_type: "view_access_key_list", finality: "final", account_id: CONTRACT })).keys;
73
- const full = keys.filter((k) => k.access_key.permission === "FullAccess").length;
74
- const up = await viewFinal("upgrade_status").catch(() => ({}));
75
- return [
76
- { ok: full === 0, what: "nobody holds a full-access key to the contract", detail: `${CONTRACT}: ${full} full-access key(s)` },
77
- { ok: !!up.renounced, what: "the contract's code can never change", detail: up.renounced ? "upgrades renounced" : "upgrades still possible" },
78
- ];
79
- }
1
+ // Re-checks one Pusaka against the chains themselves, with no server of ours in between: NEAR (the contract),
2
+ // NEAR's MPC signer, NEAR's Bitcoin light client, and Bitcoin (mempool.space, Blockstream as fallback).
3
+ // Used by the web app's "Check it yourself" and by scripts/verify.mjs for every token. Reads only.
4
+ import { base58, base64 } from "@scure/base";
5
+ import { CONTRACT, MPC, BTC_LIGHT_CLIENT, btcAddress, btcFetch, lightClientHeight, nearFetch } from "./btc.mjs";
6
+
7
+ const enc = new TextEncoder(), dec = new TextDecoder();
8
+ const hex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
9
+
10
+ async function rpc(method, params) {
11
+ const j = await (await nearFetch({ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }) })).json();
12
+ if (j.error) throw new Error(JSON.stringify(j.error).slice(0, 200));
13
+ return j.result;
14
+ }
15
+
16
+ /** A view call at final finality, straight to a NEAR RPC. */
17
+ export async function viewFinal(method, args = {}, account_id = CONTRACT) {
18
+ const r = await rpc("query", { request_type: "call_function", finality: "final", account_id, method_name: method, args_base64: base64.encode(enc.encode(JSON.stringify(args))) });
19
+ if (!r.result) throw new Error(r.error ?? `${method} failed`);
20
+ return JSON.parse(dec.decode(new Uint8Array(r.result)));
21
+ }
22
+
23
+ /** The compressed secp256k1 key NEAR's MPC derives for `path` when this contract asks. */
24
+ export async function mpcKeyFor(path, contract = CONTRACT) {
25
+ const raw = base58.decode((await viewFinal("derived_public_key", { path, predecessor: contract, domain_id: 0 }, MPC)).split(":")[1]);
26
+ return (raw[63] % 2 ? "03" : "02") + hex(raw.slice(0, 32));
27
+ }
28
+
29
+ // A Bitcoin explorer that rate-limits isn't a wrong balance: retry with backoff, then report it as unreachable.
30
+ async function btcTx(txid) {
31
+ for (let i = 0; i < 4; i++) {
32
+ const tx = await btcFetch(`/tx/${txid}`).then((r) => (r.ok ? r.json() : null)).catch(() => null);
33
+ if (tx) return tx;
34
+ await new Promise((ok) => setTimeout(ok, 1500 * 2 ** i));
35
+ }
36
+ return null;
37
+ }
38
+
39
+ /**
40
+ * Every check for one token, in order. Each is { ok, what, detail }; `ok` is null when a source couldn't be
41
+ * reached (not the same as wrong). `lc` (light-client height) can be passed in when auditing many tokens.
42
+ */
43
+ export async function auditToken(tokenId, { lc, info, contract = CONTRACT } = {}) {
44
+ info ??= await viewFinal("token_info", { token_id: String(tokenId) }, contract);
45
+ if (!info?.btc_pubkey) return [{ ok: false, what: "this Pusaka has a Bitcoin key", detail: "no key recorded yet" }];
46
+ lc ??= await lightClientHeight();
47
+ const checks = [];
48
+ const mpcKey = await mpcKeyFor(info.btc_path, contract);
49
+ const addr = btcAddress(info.btc_pubkey);
50
+ checks.push({ ok: mpcKey === info.btc_pubkey, what: "its Bitcoin key is the one NEAR's MPC derives for this contract",
51
+ detail: `${MPC} derived_public_key("${info.btc_path}", ${contract}) → ${addr}` });
52
+
53
+ const signed = new Set(info.signed_outpoints.map((o) => `${o.txid}:${o.vout}`));
54
+ const sum = info.proven_utxos.filter((u) => !signed.has(`${u.txid}:${u.vout}`)).reduce((a, u) => a + Number(u.value), 0);
55
+ checks.push({ ok: sum === Number(info.verified_sats), what: "verified_sats = proven deposits it never signed away",
56
+ detail: `${info.proven_utxos.length} proven, ${signed.size} outpoint(s) ever signed: ${sum} = ${info.verified_sats} sats` });
57
+
58
+ for (const u of info.proven_utxos) {
59
+ const tx = await btcTx(u.txid);
60
+ const what = `deposit ${u.txid.slice(0, 10)}…:${u.vout} is on Bitcoin, confirmed, ${Number(u.value)} sats to ${addr.slice(0, 10)}…`;
61
+ if (!tx) { checks.push({ ok: null, what, detail: "couldn't reach mempool.space or Blockstream (rate limit? try again)", txid: u.txid }); continue; }
62
+ const out = tx.vout?.[u.vout];
63
+ const good = !!out && out.scriptpubkey_address === addr && out.value === Number(u.value) && !!tx.status?.confirmed && tx.status.block_height <= lc;
64
+ checks.push({ ok: good, what, txid: u.txid,
65
+ detail: good ? `block ${tx.status.block_height}, light client ${BTC_LIGHT_CLIENT} at ${lc}` : `found ${out ? `${out.value} sats to ${out.scriptpubkey_address}` : "no such output"}, ${tx.status?.confirmed ? `block ${tx.status.block_height}` : "unconfirmed"}` });
66
+ }
67
+ return checks;
68
+ }
69
+
70
+ /** The contract-level facts behind every token: no access key, code final. */
71
+ export async function auditContract(contract = CONTRACT) {
72
+ const keys = (await rpc("query", { request_type: "view_access_key_list", finality: "final", account_id: contract })).keys;
73
+ const full = keys.filter((k) => k.access_key.permission === "FullAccess").length;
74
+ const up = await viewFinal("upgrade_status", {}, contract).catch(() => ({}));
75
+ return [
76
+ { ok: full === 0, what: "nobody holds a full-access key to the contract", detail: `${contract}: ${full} full-access key(s)` },
77
+ { ok: !!up.renounced, what: "the contract's code can never change", detail: up.renounced ? "upgrades renounced" : "upgrades still possible" },
78
+ ];
79
+ }
package/shared/btc.mjs CHANGED
@@ -6,6 +6,7 @@ import { ed25519 } from "@noble/curves/ed25519.js";
6
6
  import { base58, base64 } from "@scure/base";
7
7
  // Deployed accounts: deployments/<network>.json records them (code hash, deploy tx, accounts); read here.
8
8
  import TESTNET from "../deployments/testnet.json" with { type: "json" };
9
+ import TESTNET_V2 from "../deployments/testnet-v2.json" with { type: "json" };
9
10
 
10
11
  // One switch for everything network-specific. Node: PUSAKA_NETWORK=mainnet; Vite: VITE_NETWORK=mainnet.
11
12
  const env = (k) => globalThis.process?.env?.[k] ?? import.meta.env?.[`VITE_${k.replace("PUSAKA_", "")}`];
@@ -13,7 +14,9 @@ export const NETWORK = env("PUSAKA_NETWORK") ?? "testnet";
13
14
 
14
15
  const CONFIG = {
15
16
  testnet: {
16
- contract: TESTNET.contract,
17
+ // One contract: Smart Pusaka (v2) does everything v1 did and more; v1 (pusaka-btc.testnet) stays on chain, frozen,
18
+ // reachable as a sibling with ?contract=pusaka-btc.testnet, and its Pusakas can be wrapped into a v2 one.
19
+ contract: TESTNET_V2.contract,
17
20
  mpc: TESTNET.uses.mpc,
18
21
  nearRpc: "https://test.rpc.fastnear.com",
19
22
  nearRpcFallbacks: ["https://near-testnet.drpc.org", "https://rpc.testnet.near.org"],
@@ -59,7 +62,7 @@ const CONFIG = {
59
62
  if (!CONFIG) throw new Error(`unknown network ${NETWORK}`);
60
63
 
61
64
  // The web app can open a known sibling deployment with ?contract=… (allowlisted, so a link can't point it at a lookalike).
62
- export const SIBLINGS = { testnet: ["pusaka-v2.testnet"], mainnet: [] }[NETWORK];
65
+ export const SIBLINGS = { testnet: [TESTNET.contract], mainnet: [] }[NETWORK];
63
66
  const fromUrl = (() => { try { const c = new URLSearchParams(globalThis.location?.search ?? "").get("contract"); return SIBLINGS.includes(c) ? c : null; } catch { return null; } })();
64
67
  export const CONTRACT = env("PUSAKA_CONTRACT") ?? fromUrl ?? CONFIG.contract;
65
68
  export const ORIGINAL = CONFIG.contract;
@@ -375,8 +378,8 @@ const solRpc = (method, params = []) => fetch(SOL.rpc, { method: "POST", headers
375
378
  .then((r) => r.json()).then((j) => { if (j.error) throw new Error(`Solana: ${j.error.message}`); return j.result; });
376
379
 
377
380
  /** Asks the MPC contract which Solana address belongs to this token. */
378
- export async function solAddress(tokenId) {
379
- const args = { path: `pusaka-${tokenId}`, predecessor: CONTRACT, domain_id: 1 };
381
+ export async function solAddress(tokenId, contract = CONTRACT) {
382
+ const args = { path: `pusaka-${tokenId}`, predecessor: contract, domain_id: 1 };
380
383
  const r = await nearFetch({ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "query", params: {
381
384
  request_type: "call_function", finality: "optimistic", account_id: MPC, method_name: "derived_public_key",
382
385
  args_base64: base64.encode(new TextEncoder().encode(JSON.stringify(args))) } }) }).then((x) => x.json());
@@ -587,8 +590,8 @@ const nearRpc = (method, params) => nearFetch({ method: "POST", headers: { "cont
587
590
  .then((r) => r.json()).then((j) => { if (j.error) throw Object.assign(new Error(j.error.cause?.name ?? j.error.message), { rpc: j.error }); return j.result; });
588
591
 
589
592
  /** The Pusaka's NEAR account: hex of its Ed25519 MPC key. */
590
- export async function nearAccount(tokenId) {
591
- return toHex(base58.decode(await solAddress(tokenId)));
593
+ export async function nearAccount(tokenId, contract = CONTRACT) {
594
+ return toHex(base58.decode(await solAddress(tokenId, contract)));
592
595
  }
593
596
 
594
597
  /**
@@ -0,0 +1,58 @@
1
+ // "Clean to buy" (web token pages, verify.mjs, POST /api/verify): whether anything the seller already signed could still move what a buyer pays for, chain by chain.
2
+ // Built from what the token page already read (token_info, the Bitcoin explorer, each chain's nonce against the
3
+ // contract's signing floor). A check is { ok, what, detail }; ok === null means that chain couldn't be read.
4
+
5
+ /** `t`: the token page's state ({ info, bal, eth, sol, nearAcct }). */
6
+ export function cleanChecks({ info, bal, eth, sol, nearAcct }) {
7
+ const out = [];
8
+ const signed = info.signed_outpoints?.length ?? 0;
9
+ out.push({ ok: !info.pending, what: "No Bitcoin signature in progress", detail: info.pending ? "the contract is signing a withdrawal right now" : "nothing being signed" });
10
+ // a signed Bitcoin tx not yet on Bitcoin can only spend coins already left out of verified_sats: shown, not a risk
11
+ const unspent = bal?.atRisk > 0 ? `; ${bal.atRisk.toLocaleString()} sats at its address have a signature out and aren't counted` : "";
12
+ out.push({ ok: true, what: "Every coin it ever signed for is left out of the BTC shown", detail: `${signed} signed so far, recorded on NEAR${unspent}; a purchase fails if that record changes after you looked` });
13
+ // a chain this Pusaka has a key on but that wasn't read yet is "unknown", never clean (fail closed)
14
+ const unread = (what) => out.push({ ok: null, what, detail: "not read yet" });
15
+ if (bal?.loading || bal?.error) out.push({ ok: null, what: "Its Bitcoin address read", detail: bal.loading ? "still reading Bitcoin" : "the Bitcoin explorers didn't answer" });
16
+ if (info.btc_pubkey && !eth) unread("No signed EVM transaction still unused (ETH, USDC)");
17
+ if (info.btc_pubkey && !sol) unread("No SOL transfer signed in the last few minutes");
18
+ if (info.btc_pubkey && !nearAcct) unread("No signed transaction from its NEAR account still unused (NEAR, nBTC)");
19
+ if (eth) {
20
+ const bad = (eth.chains ?? []).filter((b) => b.atRisk > 0n || b.usdcAtRisk > 0n).map((b) => b.c.chain.name);
21
+ const unknown = !eth.chains?.length || eth.chains.some((b) => b.error);
22
+ out.push({ ok: bad.length ? false : unknown ? null : true, what: "No signed EVM transaction still unused (ETH, USDC)", detail: bad.length ? `pending on ${bad.join(", ")}` : unknown ? "some chains didn't answer" : `${eth.chains.length} chains checked` });
23
+ }
24
+ if (sol) out.push({ ok: sol.error ? null : !(sol.atRisk > 0n), what: "No SOL transfer signed in the last few minutes", detail: sol.error ? "Solana didn't answer" : sol.atRisk > 0n ? "one is still valid" : "none" });
25
+ if (nearAcct) out.push({ ok: nearAcct.error ? null : !(nearAcct.atRisk > 0n), what: "No signed transaction from its NEAR account still unused (NEAR, nBTC)", detail: nearAcct.error ? "NEAR didn't answer" : nearAcct.atRisk > 0n ? `nonce ${nearAcct.floor} not used yet` : "none" });
26
+ out.push({ ok: !info.loan?.lender, what: "Not collateral for a loan", detail: info.loan?.lender ? `the lender ${info.loan.lender} can claim it if it isn't repaid` : "no funded loan" });
27
+ const approvals = Object.keys(info.token.approved_account_ids ?? {});
28
+ out.push({ ok: true, what: "Approvals and the heir end at the sale", detail: approvals.length ? `${approvals.join(", ")} approved now; the contract clears every approval and the heir on transfer` : "the contract clears them on every transfer" });
29
+ return out;
30
+ }
31
+
32
+ /** "clean" | "risk" | "unknown" */
33
+ export const verdict = (checks) => checks.some((c) => c.ok === false) ? "risk" : checks.some((c) => c.ok === null) ? "unknown" : "clean";
34
+
35
+ /**
36
+ * The same checks, read straight from the chains (Node or browser): what an exchange, a CLI or a buyer's script gets.
37
+ * `view(method, args, contract)` reads NEAR. Returns { verdict, checks, verified_sats, listed }.
38
+ */
39
+ export async function cleanOf(token_id, contract, view) {
40
+ const { balances, btcAddress, evmAddress, evmBalances, EVM_CHAINS, solAddress, solBalances, nearAccount, nearBalances } = await import("./btc.mjs");
41
+ const info = await view("token_info", { token_id }, contract);
42
+ if (!info) throw new Error(`no Pusaka #${token_id} on ${contract}`);
43
+ const ethAddr = info.btc_pubkey && evmAddress(info.btc_pubkey);
44
+ const [bal, chains, sol, nearAcct] = await Promise.all([
45
+ info.btc_pubkey ? balances(btcAddress(info.btc_pubkey), info.signed_outpoints).catch(() => ({ error: true })) : { atRisk: 0 },
46
+ ethAddr ? Promise.all(EVM_CHAINS.map(async (c) => {
47
+ const floor = await view("eth_nonce_floor", { token_id, chain_id: String(c.chain.id) }, contract);
48
+ return evmBalances(ethAddr, floor, c.chain.id).then((b) => ({ ...b, c })).catch(() => ({ c, error: true }));
49
+ })).catch(() => null) : null,
50
+ solAddress(token_id, contract).then((a) => solBalances(a, info.history)).catch(() => ({ error: true })),
51
+ nearAccount(token_id, contract).then(async (a) => {
52
+ const floor = await view("near_nonce_floor", { token_id }, contract);
53
+ return { ...(await nearBalances(a, floor)), floor };
54
+ }).catch(() => ({ error: true })),
55
+ ]);
56
+ const checks = cleanChecks({ info, bal, eth: chains && { chains }, sol, nearAcct });
57
+ return { verdict: verdict(checks), checks, verified_sats: Number(info.verified_sats), listed: !!info.listing };
58
+ }