@projectsolo/solo-mission-mcp 0.21.13 → 0.22.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.
- package/DEVELOPER_README.md +1 -1
- package/README.md +96 -12
- package/dist/chunk-NUGGTARW.js +196 -0
- package/dist/{chunk-TKRT2V2W.js → chunk-OP4QYTL4.js} +5 -0
- package/dist/{client-LDWK5HLP.js → client-NS6J6IO2.js} +1 -1
- package/dist/deployment-UCUWVIPJ.js +43 -0
- package/dist/escrowErrors-4XSAYLGT.js +56 -0
- package/dist/index.js +244 -162
- package/dist/verify-7EUVRUAS.js +445 -0
- package/dist/{wallet-V4T4NTCM.js → wallet-LX7SI5RE.js} +28 -14
- package/dist/wire-RLDY4AJR.js +24 -0
- package/package.json +1 -1
- package/src/config.ts +11 -0
- package/src/index.ts +1 -1
- package/src/scripts/check-tools-against-spec.ts +3 -3
- package/src/solana/deployment.test.ts +61 -0
- package/src/solana/deployment.ts +89 -0
- package/src/solana/escrowErrors.ts +78 -0
- package/src/solana/fixtures/config-account.json +6 -0
- package/src/solana/fixtures/funding-transaction-v2-lottery.json +49 -0
- package/src/solana/fixtures/funding-transaction-v2-plain.json +46 -0
- package/src/solana/fixtures/solo_escrow.v2.idl-excerpt.json +683 -0
- package/src/solana/verify.test.ts +605 -78
- package/src/solana/verify.ts +545 -84
- package/src/solana/wallet.test.ts +85 -2
- package/src/solana/wallet.ts +74 -31
- package/src/solana/wire.test.ts +129 -0
- package/src/solana/wire.ts +264 -0
- package/src/tools/missions.ts +56 -119
- package/src/tools/solana.test.ts +356 -0
- package/src/tools/solana.ts +309 -70
- package/src/tools/tracks.ts +1 -1
- package/dist/verify-KAETIGV5.js +0 -136
- /package/src/solana/fixtures/{funding-transaction.json → funding-transaction-v1.json} +0 -0
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
*
|
|
8
8
|
* `fixtures/test-keypair.json.enc` is a throwaway, never-funded devnet keypair — safe to check in.
|
|
9
9
|
*/
|
|
10
|
-
import { describe, it, expect } from 'vitest';
|
|
11
|
-
import { readFileSync } from 'fs';
|
|
10
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
11
|
+
import { readFileSync, writeFileSync, mkdtempSync, rmSync } from 'fs';
|
|
12
|
+
import { tmpdir } from 'os';
|
|
12
13
|
import { join } from 'path';
|
|
13
14
|
import { decryptOpenSslAes256CbcPbkdf2, SolanaWalletUnavailable } from './wallet';
|
|
14
15
|
|
|
@@ -43,3 +44,85 @@ describe('decryptOpenSslAes256CbcPbkdf2', () => {
|
|
|
43
44
|
);
|
|
44
45
|
});
|
|
45
46
|
});
|
|
47
|
+
|
|
48
|
+
describe('loadSolanaWallet sources', () => {
|
|
49
|
+
const VARS = [
|
|
50
|
+
'SOLO_SOLANA_KEYPAIR',
|
|
51
|
+
'SOLO_SOLANA_KEYPAIR_PATH',
|
|
52
|
+
'SOLO_SOLANA_KEYPAIR_ENCRYPTED_PATH',
|
|
53
|
+
'SOLO_SOLANA_KEYPAIR_PASSWORD_FILE',
|
|
54
|
+
];
|
|
55
|
+
const saved: Record<string, string | undefined> = {};
|
|
56
|
+
let dir: string;
|
|
57
|
+
|
|
58
|
+
// The wallet module caches the first successful load, so each test imports a fresh copy.
|
|
59
|
+
const freshWallet = async () => {
|
|
60
|
+
vi.resetModules();
|
|
61
|
+
return import('./wallet');
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
for (const v of VARS) {
|
|
66
|
+
saved[v] = process.env[v];
|
|
67
|
+
delete process.env[v];
|
|
68
|
+
}
|
|
69
|
+
dir = mkdtempSync(join(tmpdir(), 'solo-wallet-test-'));
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
afterEach(() => {
|
|
73
|
+
for (const v of VARS) {
|
|
74
|
+
if (saved[v] === undefined) delete process.env[v];
|
|
75
|
+
else process.env[v] = saved[v];
|
|
76
|
+
}
|
|
77
|
+
rmSync(dir, { recursive: true, force: true });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('rejects the removed raw-key env var and names both replacements', async () => {
|
|
81
|
+
const w = await freshWallet();
|
|
82
|
+
process.env.SOLO_SOLANA_KEYPAIR = decryptOpenSslAes256CbcPbkdf2(encrypted, PASSPHRASE).toString('utf8');
|
|
83
|
+
// Counted as configured so get_solana_wallet surfaces this error, not generic setup help.
|
|
84
|
+
expect(w.hasSolanaWallet()).toBe(true);
|
|
85
|
+
const err = await w.loadSolanaWallet().catch((e) => e);
|
|
86
|
+
expect(err).toBeInstanceOf(w.SolanaWalletUnavailable);
|
|
87
|
+
expect(err.message).toMatch(/SOLO_SOLANA_KEYPAIR \(the raw key in an environment variable\) is no longer supported/);
|
|
88
|
+
expect(err.message).toMatch(/SOLO_SOLANA_KEYPAIR_ENCRYPTED_PATH \+\s+SOLO_SOLANA_KEYPAIR_PASSWORD_FILE/);
|
|
89
|
+
expect(err.message).toMatch(/SOLO_SOLANA_KEYPAIR_PATH/);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('rejects the removed var even when a supported source is also set', async () => {
|
|
93
|
+
const w = await freshWallet();
|
|
94
|
+
process.env.SOLO_SOLANA_KEYPAIR_ENCRYPTED_PATH = join(FIXTURE_DIR, 'test-keypair.json.enc');
|
|
95
|
+
const pass = join(dir, 'pass');
|
|
96
|
+
writeFileSync(pass, PASSPHRASE + '\n');
|
|
97
|
+
process.env.SOLO_SOLANA_KEYPAIR_PASSWORD_FILE = pass;
|
|
98
|
+
process.env.SOLO_SOLANA_KEYPAIR = '[1,2,3]';
|
|
99
|
+
await expect(w.loadSolanaWallet()).rejects.toThrow(/no longer supported/);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('loads the encrypted keyfile + passphrase file', async () => {
|
|
103
|
+
const w = await freshWallet();
|
|
104
|
+
process.env.SOLO_SOLANA_KEYPAIR_ENCRYPTED_PATH = join(FIXTURE_DIR, 'test-keypair.json.enc');
|
|
105
|
+
const pass = join(dir, 'pass');
|
|
106
|
+
writeFileSync(pass, PASSPHRASE + '\n');
|
|
107
|
+
process.env.SOLO_SOLANA_KEYPAIR_PASSWORD_FILE = pass;
|
|
108
|
+
expect(w.hasSolanaWallet()).toBe(true);
|
|
109
|
+
expect((await w.loadSolanaWallet()).publicKey).toBe(expectedPubkey);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('loads a plaintext keyfile from SOLO_SOLANA_KEYPAIR_PATH', async () => {
|
|
113
|
+
const w = await freshWallet();
|
|
114
|
+
const keyfile = join(dir, 'id.json');
|
|
115
|
+
writeFileSync(keyfile, decryptOpenSslAes256CbcPbkdf2(encrypted, PASSPHRASE));
|
|
116
|
+
process.env.SOLO_SOLANA_KEYPAIR_PATH = keyfile;
|
|
117
|
+
expect((await w.loadSolanaWallet()).publicKey).toBe(expectedPubkey);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('reports no wallet, with key-storage guidance, when nothing is set', async () => {
|
|
121
|
+
const w = await freshWallet();
|
|
122
|
+
expect(w.hasSolanaWallet()).toBe(false);
|
|
123
|
+
const err = await w.loadSolanaWallet().catch((e) => e);
|
|
124
|
+
expect(err.message).toMatch(/no wallet source configured/);
|
|
125
|
+
expect(err.message).toMatch(/secret manager/);
|
|
126
|
+
expect(err.message).not.toMatch(/SOLO_SOLANA_KEYPAIR\b(?!_)/);
|
|
127
|
+
});
|
|
128
|
+
});
|
package/src/solana/wallet.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The agent's Solana wallet.
|
|
3
3
|
*
|
|
4
|
-
* An agent needs SOL as well as USDC to sponsor a Solana mission
|
|
5
|
-
*
|
|
4
|
+
* An agent needs SOL as well as USDC to sponsor a Solana mission: SOL for transaction fees and for
|
|
5
|
+
* account rent, a refundable deposit that is easy to miss because it is not a fee:
|
|
6
6
|
*
|
|
7
7
|
* Task account rent ~0.00374 SOL ($0.374) — partly recovered on close_task
|
|
8
8
|
* TaskVault token account ~0.00204 SOL ($0.204) — recovered in full on close_task
|
|
@@ -20,23 +20,30 @@
|
|
|
20
20
|
* one. That is the whole point of the partial-signing flow: a compromised backend cannot move a
|
|
21
21
|
* sponsor's funds, because it never holds the signing key.
|
|
22
22
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
23
|
+
* WHERE THE KEY COMES FROM. Two sources, both files:
|
|
24
|
+
*
|
|
25
|
+
* SOLO_SOLANA_KEYPAIR_ENCRYPTED_PATH + SOLO_SOLANA_KEYPAIR_PASSWORD_FILE (recommended)
|
|
26
|
+
* SOLO_SOLANA_KEYPAIR_PATH (plaintext keyfile)
|
|
27
|
+
*
|
|
28
|
+
* The raw key in an environment variable (SOLO_SOLANA_KEYPAIR) is no longer accepted: env vars
|
|
29
|
+
* are inherited by every child process and end up in shell history and client config files, and
|
|
30
|
+
* the ClawHub audit penalised exactly that pattern. If it is still set, loading the wallet fails
|
|
31
|
+
* with an error naming the replacements rather than ignoring it.
|
|
32
|
+
*
|
|
33
|
+
* `solana-keygen new`'s own output is a plaintext JSON byte array — chmod 600 protects it from
|
|
34
|
+
* other users on the box, but not from whoever gets that one file (a compromised account, a stray
|
|
35
|
+
* backup, a copied disk image). The encrypted form splits the secret in two: the ciphertext and
|
|
36
|
+
* the passphrase file are only useful together. Encrypt with the exact command below so the
|
|
30
37
|
* decryptor here (which reimplements OpenSSL's KDF, not shells out to it) derives the same key:
|
|
31
38
|
*
|
|
32
39
|
* openssl enc -aes-256-cbc -pbkdf2 -iter 10000 -md sha256 \
|
|
33
40
|
* -in ~/.config/solana/solo-sponsor.json -out solo-sponsor.json.enc
|
|
34
41
|
* chmod 600 solo-sponsor.json.enc
|
|
35
|
-
* echo -n "your-passphrase" > keystore.pass && chmod 600 keystore.pass
|
|
36
42
|
* # then delete the plaintext solo-sponsor.json
|
|
37
43
|
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
44
|
+
* Keep the keyfile and the passphrase in a secret manager and have it write the files when the MCP
|
|
45
|
+
* server starts (see KEY_STORAGE_GUIDANCE). Never type either into chat, argv or shell history.
|
|
46
|
+
* Remote signing (KMS/HSM) is not supported yet.
|
|
40
47
|
*/
|
|
41
48
|
|
|
42
49
|
import { readFileSync } from 'fs';
|
|
@@ -48,16 +55,29 @@ export interface SolanaWallet {
|
|
|
48
55
|
secretKey: Uint8Array;
|
|
49
56
|
}
|
|
50
57
|
|
|
58
|
+
/** Where to keep the key. Shared by the wallet error and get_solana_wallet's setup help. */
|
|
59
|
+
export const KEY_STORAGE_GUIDANCE =
|
|
60
|
+
'Keep the keyfile and the passphrase in your secret manager and have it write the files when ' +
|
|
61
|
+
'the MCP server starts: for example 1Password (`op inject`, `op read --out-file`), a HashiCorp ' +
|
|
62
|
+
'Vault Agent template, GCP Secret Manager (`gcloud secrets versions access`), or the macOS ' +
|
|
63
|
+
'Keychain (`security find-generic-password -w`). Never paste the key or the passphrase into ' +
|
|
64
|
+
'chat, a command line or shell history.';
|
|
65
|
+
|
|
66
|
+
/** The raw-key env var this package used to accept. Setting it is now an error, not ignored. */
|
|
67
|
+
export const REMOVED_INLINE_KEY_VAR = 'SOLO_SOLANA_KEYPAIR';
|
|
68
|
+
|
|
51
69
|
export class SolanaWalletUnavailable extends Error {
|
|
52
70
|
constructor(reason: string) {
|
|
53
71
|
super(
|
|
54
72
|
`Solana wallet unavailable: ${reason}\n\n` +
|
|
55
73
|
'Set one of:\n' +
|
|
56
74
|
' SOLO_SOLANA_KEYPAIR_ENCRYPTED_PATH + SOLO_SOLANA_KEYPAIR_PASSWORD_FILE\n' +
|
|
57
|
-
' - recommended:
|
|
58
|
-
'
|
|
59
|
-
'
|
|
60
|
-
' SOLO_SOLANA_KEYPAIR_PATH - path to
|
|
75
|
+
' - recommended: a keyfile encrypted with\n' +
|
|
76
|
+
' `openssl enc -aes-256-cbc -pbkdf2 -iter 10000 -md sha256`\n' +
|
|
77
|
+
' plus a separate passphrase file (chmod 600 both)\n' +
|
|
78
|
+
' SOLO_SOLANA_KEYPAIR_PATH - path to a plaintext `solana-keygen` keyfile\n' +
|
|
79
|
+
' (e.g. ~/.config/solana/id.json, chmod 600)\n\n' +
|
|
80
|
+
`${KEY_STORAGE_GUIDANCE}\n\n` +
|
|
61
81
|
'The wallet needs SOL for rent and fees, and USDC for the mission budget. Rent is a\n' +
|
|
62
82
|
'refundable deposit, not a fee: most of it returns when the task is closed.',
|
|
63
83
|
);
|
|
@@ -65,6 +85,20 @@ export class SolanaWalletUnavailable extends Error {
|
|
|
65
85
|
}
|
|
66
86
|
}
|
|
67
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Throws if the removed raw-key env var is set. Checked before any other source, so a stale
|
|
90
|
+
* config fails loudly instead of the key sitting in the environment unnoticed.
|
|
91
|
+
*/
|
|
92
|
+
export function assertNoInlineKeyVar(): void {
|
|
93
|
+
if ((process.env[REMOVED_INLINE_KEY_VAR] ?? '').trim() !== '') {
|
|
94
|
+
throw new SolanaWalletUnavailable(
|
|
95
|
+
`${REMOVED_INLINE_KEY_VAR} (the raw key in an environment variable) is no longer supported. ` +
|
|
96
|
+
'Unset it, and move the key into a file referenced by SOLO_SOLANA_KEYPAIR_ENCRYPTED_PATH + ' +
|
|
97
|
+
'SOLO_SOLANA_KEYPAIR_PASSWORD_FILE (recommended) or SOLO_SOLANA_KEYPAIR_PATH.',
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
68
102
|
/**
|
|
69
103
|
* Decrypts a file produced by `openssl enc -aes-256-cbc -pbkdf2 -iter 10000 -md sha256`.
|
|
70
104
|
*
|
|
@@ -140,14 +174,15 @@ let cachedWallet: SolanaWallet | undefined;
|
|
|
140
174
|
* Loads the agent's wallet.
|
|
141
175
|
*
|
|
142
176
|
* Read at call time, not at module load, so a tool that never touches Solana works in a process
|
|
143
|
-
* with no wallet configured at all — an agent running
|
|
177
|
+
* with no wallet configured at all — an agent running only free off-chain missions should not
|
|
178
|
+
* need one.
|
|
144
179
|
*/
|
|
145
180
|
export async function loadSolanaWallet(): Promise<SolanaWallet> {
|
|
146
181
|
if (cachedWallet) return cachedWallet;
|
|
182
|
+
assertNoInlineKeyVar();
|
|
147
183
|
|
|
148
184
|
const encryptedPath = process.env.SOLO_SOLANA_KEYPAIR_ENCRYPTED_PATH;
|
|
149
185
|
const passwordFile = process.env.SOLO_SOLANA_KEYPAIR_PASSWORD_FILE;
|
|
150
|
-
const inline = process.env.SOLO_SOLANA_KEYPAIR;
|
|
151
186
|
const path = process.env.SOLO_SOLANA_KEYPAIR_PATH;
|
|
152
187
|
|
|
153
188
|
let raw: string;
|
|
@@ -170,8 +205,6 @@ export async function loadSolanaWallet(): Promise<SolanaWallet> {
|
|
|
170
205
|
throw new SolanaWalletUnavailable(`cannot read ${passwordFile}: ${(e as Error).message}`);
|
|
171
206
|
}
|
|
172
207
|
raw = decryptOpenSslAes256CbcPbkdf2(encrypted, password).toString('utf8');
|
|
173
|
-
} else if (inline && inline.trim() !== '') {
|
|
174
|
-
raw = inline;
|
|
175
208
|
} else if (path && path.trim() !== '') {
|
|
176
209
|
try {
|
|
177
210
|
raw = readFileSync(expandHome(path), 'utf8');
|
|
@@ -180,7 +213,7 @@ export async function loadSolanaWallet(): Promise<SolanaWallet> {
|
|
|
180
213
|
}
|
|
181
214
|
} else {
|
|
182
215
|
throw new SolanaWalletUnavailable(
|
|
183
|
-
'no wallet source configured (checked SOLO_SOLANA_KEYPAIR_ENCRYPTED_PATH,
|
|
216
|
+
'no wallet source configured (checked SOLO_SOLANA_KEYPAIR_ENCRYPTED_PATH, SOLO_SOLANA_KEYPAIR_PATH)',
|
|
184
217
|
);
|
|
185
218
|
}
|
|
186
219
|
|
|
@@ -191,13 +224,17 @@ export async function loadSolanaWallet(): Promise<SolanaWallet> {
|
|
|
191
224
|
return cachedWallet;
|
|
192
225
|
}
|
|
193
226
|
|
|
194
|
-
/**
|
|
227
|
+
/**
|
|
228
|
+
* Whether a Solana wallet is configured, without throwing. The removed raw-key var counts as
|
|
229
|
+
* "configured" on purpose: the caller then loads the wallet and surfaces the error that names the
|
|
230
|
+
* replacements, instead of showing generic setup help that never mentions the stale var.
|
|
231
|
+
*/
|
|
195
232
|
export function hasSolanaWallet(): boolean {
|
|
196
233
|
return Boolean(
|
|
197
234
|
cachedWallet ||
|
|
235
|
+
(process.env[REMOVED_INLINE_KEY_VAR] ?? '').trim() ||
|
|
198
236
|
((process.env.SOLO_SOLANA_KEYPAIR_ENCRYPTED_PATH ?? '').trim() &&
|
|
199
237
|
(process.env.SOLO_SOLANA_KEYPAIR_PASSWORD_FILE ?? '').trim()) ||
|
|
200
|
-
(process.env.SOLO_SOLANA_KEYPAIR ?? '').trim() ||
|
|
201
238
|
(process.env.SOLO_SOLANA_KEYPAIR_PATH ?? '').trim(),
|
|
202
239
|
);
|
|
203
240
|
}
|
|
@@ -221,16 +258,22 @@ export async function associatedTokenAddress(mint: string, owner: string): Promi
|
|
|
221
258
|
return address.toBase58();
|
|
222
259
|
}
|
|
223
260
|
|
|
224
|
-
/**
|
|
261
|
+
/**
|
|
262
|
+
* Signs a base64 transaction built by the backend, returning it base64-encoded.
|
|
263
|
+
*
|
|
264
|
+
* Fills in ONLY this wallet's signature slot, on the wire bytes. A v2 lottery funding transaction
|
|
265
|
+
* arrives already co-signed by the platform Operator over the exact message; the message and every
|
|
266
|
+
* other signature are left byte-for-byte as received — never stripped, reordered or re-serialised —
|
|
267
|
+
* and that is asserted before the result is returned (see `addOwnSignature`).
|
|
268
|
+
*/
|
|
225
269
|
export async function signTransaction(
|
|
226
270
|
transactionBase64: string,
|
|
227
271
|
wallet: SolanaWallet,
|
|
228
272
|
): Promise<string> {
|
|
229
|
-
const {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
return tx.serialize().toString('base64');
|
|
273
|
+
const { addOwnSignature } = await import('./wire.js');
|
|
274
|
+
return addOwnSignature(
|
|
275
|
+
Buffer.from(transactionBase64, 'base64'),
|
|
276
|
+
wallet.secretKey,
|
|
277
|
+
wallet.publicKey,
|
|
278
|
+
).toString('base64');
|
|
236
279
|
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wire decoder and the signer. The signer is what keeps a lottery's Operator co-signature
|
|
3
|
+
* intact, so it is checked byte-for-byte: against web3.js for a single-signer transaction, and
|
|
4
|
+
* against the real co-signed fixture for the two-signer case.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect } from 'vitest'
|
|
7
|
+
import { readFileSync } from 'fs'
|
|
8
|
+
import { join } from 'path'
|
|
9
|
+
import { Keypair, PublicKey, Transaction } from '@solana/web3.js'
|
|
10
|
+
import {
|
|
11
|
+
addOwnSignature,
|
|
12
|
+
base58Decode,
|
|
13
|
+
base58Encode,
|
|
14
|
+
ed25519Verify,
|
|
15
|
+
isEmptySignature,
|
|
16
|
+
isSignerIndex,
|
|
17
|
+
isWritableIndex,
|
|
18
|
+
parseWireTransaction,
|
|
19
|
+
publicKeyOfSecret,
|
|
20
|
+
} from './wire'
|
|
21
|
+
import { decryptOpenSslAes256CbcPbkdf2, signTransaction } from './wallet'
|
|
22
|
+
|
|
23
|
+
const FIX = join(__dirname, 'fixtures')
|
|
24
|
+
const load = (f: string) => JSON.parse(readFileSync(join(FIX, f), 'utf8'))
|
|
25
|
+
const lottery = load('funding-transaction-v2-lottery.json')
|
|
26
|
+
const plain = load('funding-transaction-v2-plain.json')
|
|
27
|
+
const raw = (fx: any) => Buffer.from(fx.response.transaction_base64, 'base64')
|
|
28
|
+
|
|
29
|
+
// The never-funded test sponsor both v2 fixtures were built for.
|
|
30
|
+
const sponsorSecret = Uint8Array.from(
|
|
31
|
+
JSON.parse(
|
|
32
|
+
decryptOpenSslAes256CbcPbkdf2(
|
|
33
|
+
readFileSync(join(FIX, 'test-keypair.json.enc')),
|
|
34
|
+
'test-only-fixture-passphrase',
|
|
35
|
+
).toString('utf8'),
|
|
36
|
+
),
|
|
37
|
+
)
|
|
38
|
+
const sponsor = Keypair.fromSecretKey(sponsorSecret)
|
|
39
|
+
const wallet = { publicKey: sponsor.publicKey.toBase58(), secretKey: sponsorSecret }
|
|
40
|
+
|
|
41
|
+
describe('parseWireTransaction', () => {
|
|
42
|
+
for (const [label, fx] of [['lottery', lottery], ['non-lottery', plain]] as const) {
|
|
43
|
+
it(`decodes the ${label} fixture exactly as web3.js does`, () => {
|
|
44
|
+
const ours = parseWireTransaction(raw(fx))
|
|
45
|
+
const theirs = Transaction.from(raw(fx))
|
|
46
|
+
expect(ours.accountKeys[0]).toBe(theirs.feePayer!.toBase58())
|
|
47
|
+
expect(ours.recentBlockhash).toBe(theirs.recentBlockhash)
|
|
48
|
+
expect(ours.message.equals(theirs.serializeMessage())).toBe(true)
|
|
49
|
+
expect(ours.instructions).toHaveLength(theirs.instructions.length)
|
|
50
|
+
const ix = ours.instructions[0]
|
|
51
|
+
const tix = theirs.instructions[0]
|
|
52
|
+
expect(ours.accountKeys[ix.programIdIndex]).toBe(tix.programId.toBase58())
|
|
53
|
+
expect(ix.data.equals(tix.data)).toBe(true)
|
|
54
|
+
ix.accountIndexes.forEach((k, i) => {
|
|
55
|
+
expect(ours.accountKeys[k]).toBe(tix.keys[i].pubkey.toBase58())
|
|
56
|
+
expect(isSignerIndex(ours, k)).toBe(tix.keys[i].isSigner)
|
|
57
|
+
expect(isWritableIndex(ours, k)).toBe(tix.keys[i].isWritable)
|
|
58
|
+
})
|
|
59
|
+
expect(ours.signatures.map((s) => s.toString('hex'))).toEqual(
|
|
60
|
+
theirs.signatures.map((s) => (s.signature ? Buffer.from(s.signature).toString('hex') : '00'.repeat(64))),
|
|
61
|
+
)
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
it('refuses trailing bytes, truncation and versioned messages', () => {
|
|
66
|
+
expect(() => parseWireTransaction(Buffer.concat([raw(plain), Buffer.from([0])]))).toThrow(/trailing/)
|
|
67
|
+
expect(() => parseWireTransaction(raw(plain).subarray(0, 100))).toThrow(/truncated/)
|
|
68
|
+
const versioned = Buffer.from(raw(plain))
|
|
69
|
+
versioned[1 + 64] |= 0x80
|
|
70
|
+
expect(() => parseWireTransaction(versioned)).toThrow(/versioned/)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('base58 round-trips like PublicKey', () => {
|
|
74
|
+
for (const k of [PublicKey.default, new PublicKey(lottery.sponsor), Keypair.generate().publicKey]) {
|
|
75
|
+
expect(base58Encode(k.toBytes())).toBe(k.toBase58())
|
|
76
|
+
expect(base58Decode(k.toBase58()).equals(Buffer.from(k.toBytes()))).toBe(true)
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe('addOwnSignature', () => {
|
|
82
|
+
it('produces exactly what web3.js partialSign produces for a single-signer transaction', () => {
|
|
83
|
+
const t = Transaction.from(raw(plain))
|
|
84
|
+
t.partialSign(sponsor)
|
|
85
|
+
const ours = addOwnSignature(raw(plain), sponsorSecret, wallet.publicKey)
|
|
86
|
+
expect(ours.equals(t.serialize())).toBe(true)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('keeps the Operator co-signature byte-for-byte and adds a valid sponsor signature', () => {
|
|
90
|
+
const before = parseWireTransaction(raw(lottery))
|
|
91
|
+
const operator = before.accountKeys[1]
|
|
92
|
+
expect(ed25519Verify(before.signatures[1], before.message, operator)).toBe(true)
|
|
93
|
+
expect(isEmptySignature(before.signatures[0])).toBe(true)
|
|
94
|
+
|
|
95
|
+
const after = parseWireTransaction(addOwnSignature(raw(lottery), sponsorSecret, wallet.publicKey))
|
|
96
|
+
expect(after.message.equals(before.message)).toBe(true)
|
|
97
|
+
expect(after.signatures[1].equals(before.signatures[1])).toBe(true)
|
|
98
|
+
expect(ed25519Verify(after.signatures[1], after.message, operator)).toBe(true)
|
|
99
|
+
expect(ed25519Verify(after.signatures[0], after.message, wallet.publicKey)).toBe(true)
|
|
100
|
+
// What the runtime checks: every required signature present and valid.
|
|
101
|
+
expect(Transaction.from(addOwnSignature(raw(lottery), sponsorSecret, wallet.publicKey)).verifySignatures()).toBe(true)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('refuses to sign a transaction this wallet is not a signer of', () => {
|
|
105
|
+
const other = Keypair.generate()
|
|
106
|
+
expect(() => addOwnSignature(raw(plain), other.secretKey, other.publicKey.toBase58())).toThrow(
|
|
107
|
+
/not a required signer/,
|
|
108
|
+
)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('refuses a secret key that does not derive the claimed public key', () => {
|
|
112
|
+
expect(() => addOwnSignature(raw(plain), Keypair.generate().secretKey, wallet.publicKey)).toThrow(
|
|
113
|
+
/does not derive/,
|
|
114
|
+
)
|
|
115
|
+
expect(publicKeyOfSecret(sponsorSecret)).toBe(wallet.publicKey)
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
describe('signTransaction (wallet)', () => {
|
|
120
|
+
it('signs a co-signed lottery transaction without disturbing the co-signature', async () => {
|
|
121
|
+
const signed = Buffer.from(await signTransaction(lottery.response.transaction_base64, wallet), 'base64')
|
|
122
|
+
const before = raw(lottery)
|
|
123
|
+
expect(signed.length).toBe(before.length)
|
|
124
|
+
const tx = parseWireTransaction(signed)
|
|
125
|
+
// Only the sponsor's 64 bytes changed.
|
|
126
|
+
expect(signed.subarray(tx.signatureOffset + 64).equals(before.subarray(tx.signatureOffset + 64))).toBe(true)
|
|
127
|
+
expect(ed25519Verify(tx.signatures[0], tx.message, wallet.publicKey)).toBe(true)
|
|
128
|
+
})
|
|
129
|
+
})
|