@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.
Files changed (34) hide show
  1. package/DEVELOPER_README.md +1 -1
  2. package/README.md +96 -12
  3. package/dist/chunk-NUGGTARW.js +196 -0
  4. package/dist/{chunk-TKRT2V2W.js → chunk-OP4QYTL4.js} +5 -0
  5. package/dist/{client-LDWK5HLP.js → client-NS6J6IO2.js} +1 -1
  6. package/dist/deployment-UCUWVIPJ.js +43 -0
  7. package/dist/escrowErrors-4XSAYLGT.js +56 -0
  8. package/dist/index.js +244 -162
  9. package/dist/verify-7EUVRUAS.js +445 -0
  10. package/dist/{wallet-V4T4NTCM.js → wallet-LX7SI5RE.js} +28 -14
  11. package/dist/wire-RLDY4AJR.js +24 -0
  12. package/package.json +1 -1
  13. package/src/config.ts +11 -0
  14. package/src/index.ts +1 -1
  15. package/src/scripts/check-tools-against-spec.ts +3 -3
  16. package/src/solana/deployment.test.ts +61 -0
  17. package/src/solana/deployment.ts +89 -0
  18. package/src/solana/escrowErrors.ts +78 -0
  19. package/src/solana/fixtures/config-account.json +6 -0
  20. package/src/solana/fixtures/funding-transaction-v2-lottery.json +49 -0
  21. package/src/solana/fixtures/funding-transaction-v2-plain.json +46 -0
  22. package/src/solana/fixtures/solo_escrow.v2.idl-excerpt.json +683 -0
  23. package/src/solana/verify.test.ts +605 -78
  24. package/src/solana/verify.ts +545 -84
  25. package/src/solana/wallet.test.ts +85 -2
  26. package/src/solana/wallet.ts +74 -31
  27. package/src/solana/wire.test.ts +129 -0
  28. package/src/solana/wire.ts +264 -0
  29. package/src/tools/missions.ts +56 -119
  30. package/src/tools/solana.test.ts +356 -0
  31. package/src/tools/solana.ts +309 -70
  32. package/src/tools/tracks.ts +1 -1
  33. package/dist/verify-KAETIGV5.js +0 -136
  34. /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
+ });
@@ -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, and that is new — on Base it
5
- * needed only ETH for gas and USDC for the budget. The rent line has no EVM analogue:
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
- * AT REST, prefer SOLO_SOLANA_KEYPAIR_ENCRYPTED_PATH + SOLO_SOLANA_KEYPAIR_PASSWORD_FILE over the
24
- * plain SOLO_SOLANA_KEYPAIR / SOLO_SOLANA_KEYPAIR_PATH pair. `solana-keygen new`'s own output is a
25
- * plaintext JSON byte array — chmod 600 protects it from other users on the box, but not from
26
- * whoever gets that one file (a compromised account, a stray backup, a copied disk image). The
27
- * encrypted form splits the secret in two: the ciphertext and the passphrase file are only useful
28
- * together, matching what `references/wallet-setup.md` already does for the Base private key
29
- * (openssl-encrypt at rest, decrypt only transiently). Encrypt with the exact command below so the
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
- * The plain SOLO_SOLANA_KEYPAIR / SOLO_SOLANA_KEYPAIR_PATH pair still works, unchanged, for
39
- * quick-start / devnet use where this doesn't matter.
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: an openssl-aes-256-cbc-pbkdf2-encrypted\n' +
58
- ' keyfile plus a separate passphrase file (chmod 600 both)\n' +
59
- ' SOLO_SOLANA_KEYPAIR - JSON byte array, as `solana-keygen` writes it (plaintext)\n' +
60
- ' SOLO_SOLANA_KEYPAIR_PATH - path to that file (e.g. ~/.config/solana/id.json), plaintext\n\n' +
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 Base missions should not need one.
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, SOLO_SOLANA_KEYPAIR, SOLO_SOLANA_KEYPAIR_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
- /** Whether a Solana wallet is configured, without throwing. */
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
- /** Signs a base64 transaction built by the backend, returning it base64-encoded. */
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 { Keypair, Transaction } = await import('@solana/web3.js');
230
- const kp = Keypair.fromSecretKey(wallet.secretKey);
231
- const tx = Transaction.from(Buffer.from(transactionBase64, 'base64'));
232
- // partialSign, not sign: the transaction may carry other signature slots, and `sign` would
233
- // discard them.
234
- tx.partialSign(kp);
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
+ })