@geeqdev/geeq-sdk 0.1.0 → 0.1.1

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 (122) hide show
  1. package/.idea/geeq_sdk.iml +12 -0
  2. package/.idea/modules.xml +8 -0
  3. package/.idea/vcs.xml +6 -0
  4. package/.nvmrc +1 -0
  5. package/CONTRIBUTING.md +20 -0
  6. package/example/.env.example +4 -0
  7. package/example/README.md +304 -0
  8. package/example/USAGE.md +74 -0
  9. package/example/index.html +12 -0
  10. package/example/package-lock.json +1557 -0
  11. package/example/package.json +25 -0
  12. package/example/scripts/browser-check.mjs +1421 -0
  13. package/example/scripts/match-derivation.mjs +65 -0
  14. package/example/src/App.vue +138 -0
  15. package/example/src/components/AuthorityPanel.vue +277 -0
  16. package/example/src/components/ChainPanel.vue +267 -0
  17. package/example/src/components/ChainState.vue +59 -0
  18. package/example/src/components/CopyButton.vue +25 -0
  19. package/example/src/components/CreateAssetPanel.vue +313 -0
  20. package/example/src/components/DigitalAssetsPanel.vue +68 -0
  21. package/example/src/components/EvidencePanel.vue +396 -0
  22. package/example/src/components/ExplorerPanel.vue +119 -0
  23. package/example/src/components/KeypairPanel.vue +182 -0
  24. package/example/src/components/PhrasePanel.vue +105 -0
  25. package/example/src/components/SocketWarning.vue +18 -0
  26. package/example/src/components/StepGate.vue +24 -0
  27. package/example/src/components/TransferPanel.vue +416 -0
  28. package/example/src/components/TxDeadline.vue +19 -0
  29. package/example/src/components/TxFee.vue +31 -0
  30. package/example/src/components/TxStatus.vue +105 -0
  31. package/example/src/composables/useSocketConnection.ts +80 -0
  32. package/example/src/env.d.ts +12 -0
  33. package/example/src/main.ts +5 -0
  34. package/example/src/pennies.ts +7 -0
  35. package/example/src/style.css +149 -0
  36. package/example/tsconfig.json +19 -0
  37. package/example/vite.config.ts +26 -0
  38. package/jest.config.js +18 -0
  39. package/package.json +1 -5
  40. package/scripts/copy-wasm.mjs +21 -0
  41. package/scripts/smoke.mjs +382 -0
  42. package/src/address/record-type.ts +77 -0
  43. package/src/address/wallet-address.ts +105 -0
  44. package/src/amount.ts +143 -0
  45. package/src/assert.ts +30 -0
  46. package/src/capnp/README.md +15 -0
  47. package/src/capnp/adminNetTx.capnp.ts +66 -0
  48. package/src/capnp/appLayBlock.capnp.ts +26 -0
  49. package/src/capnp/appLayLedger.capnp.ts +35 -0
  50. package/src/capnp/appLayRec.capnp.ts +159 -0
  51. package/src/capnp/chainStateView.capnp.ts +49 -0
  52. package/src/capnp/coinAcctRec.capnp.ts +193 -0
  53. package/src/capnp/genesisBlock.capnp.ts +56 -0
  54. package/src/capnp/hubContentStmt.capnp.ts +59 -0
  55. package/src/capnp/misc.capnp.ts +19 -0
  56. package/src/capnp/netActorRec.capnp.ts +64 -0
  57. package/src/capnp/nodeContentStmt.capnp.ts +62 -0
  58. package/src/capnp/nodePayloadList.capnp.ts +28 -0
  59. package/src/capnp/nodeTxList.capnp.ts +37 -0
  60. package/src/capnp/paramAcctRec.capnp.ts +669 -0
  61. package/src/capnp/payload.capnp.ts +261 -0
  62. package/src/capnp/proxyAcctRec.capnp.ts +259 -0
  63. package/src/capnp/relayContentStmt.capnp.ts +85 -0
  64. package/src/capnp/sysAcctRec.capnp.ts +47 -0
  65. package/src/capnp/userAppTx.capnp.ts +269 -0
  66. package/src/capnp/userValTx.capnp.ts +1999 -0
  67. package/src/capnp/valLayBlock.capnp.ts +93 -0
  68. package/src/capnp/valLayLedger.capnp.ts +80 -0
  69. package/src/chain/geeq-chain.ts +1094 -0
  70. package/src/chain/geeq-node.ts +105 -0
  71. package/src/chain/nonce-guard.ts +73 -0
  72. package/src/chain/select.ts +149 -0
  73. package/src/crypto/account-id.ts +83 -0
  74. package/src/crypto/keypair.ts +221 -0
  75. package/src/crypto/mnemonic.ts +69 -0
  76. package/src/encoding/base64.ts +58 -0
  77. package/src/encoding/hex.ts +42 -0
  78. package/src/errors.ts +122 -0
  79. package/src/index.ts +195 -0
  80. package/src/ledger/coin-accounts.ts +73 -0
  81. package/src/ledger/decode.ts +147 -0
  82. package/src/ledger/geeq-ledger.ts +184 -0
  83. package/src/ledger/net-actors.ts +129 -0
  84. package/src/ledger/nft-records.ts +49 -0
  85. package/src/ledger/params.ts +82 -0
  86. package/src/ledger/proxy-accounts.ts +51 -0
  87. package/src/net/discovery.ts +231 -0
  88. package/src/net/explorer.ts +123 -0
  89. package/src/net/read-ledger.ts +58 -0
  90. package/src/net/routes.ts +79 -0
  91. package/src/net/transport.ts +221 -0
  92. package/src/net/websocket.ts +94 -0
  93. package/src/runtime/bindings.ts +38 -0
  94. package/src/runtime/init.ts +49 -0
  95. package/src/runtime/wasm-loader.browser.ts +22 -0
  96. package/src/runtime/wasm-loader.node.ts +16 -0
  97. package/src/socket/geeq-socket.ts +827 -0
  98. package/src/socket/tracked-tx.ts +319 -0
  99. package/src/socket/tx-status.ts +131 -0
  100. package/src/tx/attestation.ts +222 -0
  101. package/src/tx/content.ts +82 -0
  102. package/src/tx/encode.ts +107 -0
  103. package/src/tx/expiry.ts +11 -0
  104. package/src/tx/fee.ts +31 -0
  105. package/src/tx/metadata.ts +100 -0
  106. package/src/tx/nft.ts +108 -0
  107. package/src/tx/proxy.ts +156 -0
  108. package/src/tx/transfer.ts +162 -0
  109. package/src/types.ts +503 -0
  110. package/src/wasm/README.md +22 -0
  111. package/src/wasm/browser/geeq_core.d.ts +1224 -0
  112. package/src/wasm/browser/geeq_core.js +6014 -0
  113. package/src/wasm/browser/geeq_core_bg.wasm +0 -0
  114. package/src/wasm/browser/geeq_core_bg.wasm.d.ts +447 -0
  115. package/src/wasm/nodejs/geeq_core.d.ts +753 -0
  116. package/src/wasm/nodejs/geeq_core.js +6016 -0
  117. package/src/wasm/nodejs/geeq_core_bg.wasm +0 -0
  118. package/src/wasm/nodejs/geeq_core_bg.wasm.d.ts +447 -0
  119. package/src/wasm/nodejs/package.json +3 -0
  120. package/tsconfig.build.json +8 -0
  121. package/tsconfig.json +20 -0
  122. package/tsup.config.ts +60 -0
@@ -0,0 +1,105 @@
1
+ import type { GeeqLedger } from '../ledger/geeq-ledger'
2
+ import type { GeeqNodeRole, GeeqNodeStatus } from '../ledger/net-actors'
3
+ import { readLedger } from '../net/read-ledger'
4
+ import { submitRoute } from '../net/routes'
5
+ import type { GeeqTransport } from '../net/transport'
6
+
7
+ /** @internal */
8
+ export interface NodeRequestConfig {
9
+ readonly transport: GeeqTransport
10
+ readonly timeoutMs: number
11
+ readonly retries: number
12
+ readonly headers: Readonly<Record<string, string>> | undefined
13
+ }
14
+
15
+ /** @internal */
16
+ export interface GeeqNodeInit extends NodeRequestConfig {
17
+ readonly url: string
18
+ readonly nodeNumber: number
19
+ readonly host: string
20
+ readonly port: number
21
+ readonly role: GeeqNodeRole
22
+ readonly status: GeeqNodeStatus
23
+ readonly processing: boolean
24
+ }
25
+
26
+ /**
27
+ * One node, identified in its own chain's ledger.
28
+ *
29
+ * A node is only meaningful once a ledger has confirmed which actor it is: the number a
30
+ * transaction must carry is the node's own `nodeNumSelf`, checked with strict equality,
31
+ * and nothing outside the ledger reports it.
32
+ */
33
+ export class GeeqNode {
34
+ /** Absolute origin, no trailing slash. */
35
+ readonly url: string
36
+ /** `nodeNumSelf` — what a transaction targeting this node must name. */
37
+ readonly nodeNumber: number
38
+ /** The bare IP the ledger records for it. */
39
+ readonly host: string
40
+ /** Its own listen port from the ledger, which is not necessarily {@link url}'s. */
41
+ readonly port: number
42
+ readonly role: GeeqNodeRole
43
+ readonly status: GeeqNodeStatus
44
+ /** Whether it accepts transactions at all. */
45
+ readonly processing: boolean
46
+
47
+ // A TypeScript `private`, never a `#private`: a `#private` field is unreachable
48
+ // through a Proxy, which breaks Vue's ref() — the trap already documented on Keypair.
49
+ private readonly request!: NodeRequestConfig
50
+
51
+ /** @internal */
52
+ constructor(init: GeeqNodeInit) {
53
+ this.url = init.url
54
+ this.nodeNumber = init.nodeNumber
55
+ this.host = init.host
56
+ this.port = init.port
57
+ this.role = init.role
58
+ this.status = init.status
59
+ this.processing = init.processing
60
+ Object.defineProperty(this, 'request', {
61
+ value: Object.freeze({
62
+ transport: init.transport,
63
+ timeoutMs: init.timeoutMs,
64
+ retries: init.retries,
65
+ headers: init.headers,
66
+ }),
67
+ enumerable: false,
68
+ writable: false,
69
+ })
70
+ }
71
+
72
+ /**
73
+ * Where a transaction built for this node must be POSTed.
74
+ *
75
+ * The SDK does not submit. This exists so an application posts to the node the SDK
76
+ * actually read from, instead of keeping a second copy of the URL that can drift.
77
+ */
78
+ get submitUrl(): string {
79
+ return submitRoute(this.url)
80
+ }
81
+
82
+ /** Read this node's current ledger. No caching — {@link GeeqChain} owns that. */
83
+ async readLedger(options: { readonly signal?: AbortSignal } = {}): Promise<GeeqLedger> {
84
+ return readLedger(this.url, {
85
+ ...this.request,
86
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
87
+ })
88
+ }
89
+
90
+ toJSON(): Record<string, unknown> {
91
+ return {
92
+ url: this.url,
93
+ nodeNumber: this.nodeNumber,
94
+ host: this.host,
95
+ port: this.port,
96
+ role: this.role,
97
+ status: this.status,
98
+ processing: this.processing,
99
+ }
100
+ }
101
+
102
+ toString(): string {
103
+ return `GeeqNode(${this.nodeNumber} at ${this.url}, ${this.role}/${this.status})`
104
+ }
105
+ }
@@ -0,0 +1,73 @@
1
+ import { nonceCollision } from '../errors'
2
+
3
+ /**
4
+ * Stops one account producing two transactions against one block.
5
+ *
6
+ * The ledger only reflects committed blocks, so building twice inside a block interval
7
+ * derives the same nonce twice — and the node invalidates **every** transaction in a
8
+ * duplicate-nonce group rather than keeping the first
9
+ * (geeq_node_core/src/validations/workers/joint/checks/9.nonce.ts). The user would see
10
+ * one success and lose both.
11
+ *
12
+ * Auto-incrementing instead was considered and rejected. The node's full check requires
13
+ * nonces consecutive from the ledger's, so handing out n+1 and then submitting only that
14
+ * one leaves a permanent gap. Since this SDK does not submit, it never learns which
15
+ * transactions actually went out, so it cannot safely keep a counter. Refusing turns a
16
+ * silent double loss into an error naming the ways forward — and an application that
17
+ * knows a build was never sent hands its nonce back with {@link release}.
18
+ */
19
+ export class NonceGuard {
20
+ /** Per account: the ledger block its last build was against, and that build's id. */
21
+ private readonly issued = new Map<string, { block: number; transaction: string }>()
22
+
23
+ /**
24
+ * Whether claiming now would collide, without recording anything.
25
+ *
26
+ * Lets a caller tell "this account already built against this block" apart from
27
+ * "the ledger we are looking at is simply out of date", which are the same thing from
28
+ * a cached read and very different from a fresh one.
29
+ */
30
+ wouldCollide(accountKey: string, ledgerBlock: number): boolean {
31
+ const previous = this.issued.get(accountKey)
32
+ return previous !== undefined && previous.block >= ledgerBlock
33
+ }
34
+
35
+ /**
36
+ * Record that `accountKey` is taking a nonce at `ledgerBlock`, for the transaction
37
+ * identified by `transaction`.
38
+ *
39
+ * @throws a `nonce-collision` GeeqError if it already took one at that block.
40
+ */
41
+ claim(accountKey: string, ledgerBlock: number, nonce: number, transaction: string): void {
42
+ const previous = this.issued.get(accountKey)
43
+
44
+ if (previous !== undefined && previous.block >= ledgerBlock) {
45
+ throw nonceCollision(
46
+ `This account already has a transaction built with nonce ${nonce} against ` +
47
+ `block ${previous.block}, and the ledger has not advanced past it. Two ` +
48
+ 'transactions from one account sharing a nonce are both rejected, not just the ' +
49
+ 'second. If the first was never sent, discard(tx) it and build again. Otherwise ' +
50
+ 'submit it and wait for the next block, pass nonce: ' +
51
+ `${nonce + 1} explicitly if you mean to submit both together, or call ` +
52
+ 'refresh() once the chain has moved on.',
53
+ )
54
+ }
55
+
56
+ this.issued.set(accountKey, { block: ledgerBlock, transaction })
57
+ }
58
+
59
+ /**
60
+ * Give back the nonce a transaction claimed, because it will never be sent. Nothing
61
+ * happens unless it is still its account's latest claim.
62
+ */
63
+ release(transaction: string): void {
64
+ for (const [accountKey, claim] of this.issued) {
65
+ if (claim.transaction === transaction) this.issued.delete(accountKey)
66
+ }
67
+ }
68
+
69
+ /** Forget every claim. For a key change, or after abandoning a built transaction. */
70
+ clear(): void {
71
+ this.issued.clear()
72
+ }
73
+ }
@@ -0,0 +1,149 @@
1
+ import { invalidArgument, nodeUnusable } from '../errors'
2
+ import type { GeeqLedger } from '../ledger/geeq-ledger'
3
+ import type { GeeqLedgerNode } from '../ledger/net-actors'
4
+
5
+ /**
6
+ * The port every Geeq node answers client traffic on.
7
+ *
8
+ * Nodes terminate TLS on 2082 and that is the only client-facing port; an endpoint
9
+ * given without one is resolved to it rather than to the scheme's default, because a
10
+ * request to 443 would fail opaquely in a browser with no way to tell why.
11
+ */
12
+ export const GEEQ_NODE_PORT = 2082
13
+
14
+ /** The host part of a node URL, which is what the net actor list records. */
15
+ export function hostOf(url: string | URL): string {
16
+ try {
17
+ return new URL(String(url)).hostname
18
+ } catch (cause) {
19
+ throw invalidArgument(
20
+ `endpoint must be an absolute URL such as https://1.2.3.4:2082, received ${JSON.stringify(String(url))}.`,
21
+ cause,
22
+ )
23
+ }
24
+ }
25
+
26
+ /** Normalise a node URL to an origin with no trailing slash, so it is safe to append to. */
27
+ export function originOf(url: string | URL): string {
28
+ hostOf(url) // same validation, same message
29
+ const parsed = new URL(String(url))
30
+ const port = parsed.port === '' ? String(GEEQ_NODE_PORT) : parsed.port
31
+ return `${parsed.protocol}//${parsed.hostname}:${port}${parsed.pathname.replace(/\/+$/u, '')}`
32
+ }
33
+
34
+ /**
35
+ * Normalise an ordinary service URL, leaving its port exactly as given.
36
+ *
37
+ * Distinct from {@link originOf} on purpose: a node directory is a normal web service
38
+ * on whatever port it was deployed on, so filling in the Geeq node port would send
39
+ * every request to the wrong place.
40
+ */
41
+ export function serviceOriginOf(url: string | URL): string {
42
+ hostOf(url)
43
+ const parsed = new URL(String(url))
44
+ return `${parsed.protocol}//${parsed.host}${parsed.pathname.replace(/\/+$/u, '')}`
45
+ }
46
+
47
+ function listAlternatives(ledger: GeeqLedger): string {
48
+ const processing = ledger.processingNodes.filter((node) => !node.retiring)
49
+ if (processing.length === 0) {
50
+ return 'This ledger lists no node that is currently processing transactions.'
51
+ }
52
+ const listed = processing
53
+ .map((node) => `${node.host} (node ${node.nodeNumber})`)
54
+ .join(', ')
55
+ return `Nodes in this ledger that are processing: ${listed}.`
56
+ }
57
+
58
+ function assertUsable(node: GeeqLedgerNode, ledger: GeeqLedger): GeeqLedgerNode {
59
+ if (!node.processing) {
60
+ // Name the role and status verbatim: they are what the operator sees too, and the
61
+ // node's own rejection ('not processing') says nothing about which is wrong.
62
+ const detail =
63
+ node.role === 'relay'
64
+ ? 'a relay registers no transaction handler at all'
65
+ : 'only anon-node, anchor-node or cert-node with status node or hub processes transactions'
66
+ throw nodeUnusable(
67
+ `Node ${node.nodeNumber} at ${node.host} is a ${node.role} with status ` +
68
+ `"${node.status}", so it will not accept this transaction — ${detail}. ` +
69
+ listAlternatives(ledger),
70
+ )
71
+ }
72
+
73
+ if (node.retiring) {
74
+ // The node checks its own rotation deadline before accepting anything and answers
75
+ // 503 'Block chain is stopped' once it is within two blocks of it. That is knowable
76
+ // here, and a 503 naming a stopped chain is a badly misleading thing to hand someone
77
+ // whose chain is running perfectly well.
78
+ throw nodeUnusable(
79
+ `Node ${node.nodeNumber} at ${node.host} leaves the active set at block ` +
80
+ `${node.retiresAtBlock} and the ledger is already at ${ledger.blockNumber}, so it ` +
81
+ 'will answer 503 "Block chain is stopped" rather than accept a transaction. This ' +
82
+ `is the node rotating out, not the chain halting. ${listAlternatives(ledger)}`,
83
+ )
84
+ }
85
+
86
+ return node
87
+ }
88
+
89
+ export interface SelectNodeOptions {
90
+ /** Skip host matching entirely. Required behind a gateway or a DNS name. */
91
+ readonly nodeNumber?: number | undefined
92
+ }
93
+
94
+ /**
95
+ * Choose the net actor record that a transaction should name.
96
+ *
97
+ * Matching is on **host only**. A record's `port` is the node's own listen port, while
98
+ * a client reaches it through a proxy on another one, so requiring both to agree finds
99
+ * nothing on a correctly deployed chain. Port is used solely to disambiguate when one
100
+ * address carries several records.
101
+ *
102
+ * This never silently retargets. A different node means a different address, a
103
+ * different certificate, a different CORS origin and a different operator seeing the
104
+ * transaction, so an unusable node is reported with its alternatives and the caller
105
+ * decides.
106
+ */
107
+ export function selectNode(
108
+ ledger: GeeqLedger,
109
+ endpoint: string | URL,
110
+ options: SelectNodeOptions = {},
111
+ ): GeeqLedgerNode {
112
+ if (options.nodeNumber !== undefined) {
113
+ const named = ledger.findNode({ nodeNumber: options.nodeNumber })
114
+ if (named === undefined) {
115
+ throw nodeUnusable(
116
+ `nodeNumber ${options.nodeNumber} is not in the net actor list of the ledger ` +
117
+ `served by ${String(endpoint)}, which lists ${ledger.nodes.length} records. ` +
118
+ listAlternatives(ledger),
119
+ )
120
+ }
121
+ return assertUsable(named, ledger)
122
+ }
123
+
124
+ const host = hostOf(endpoint)
125
+ const candidates = ledger.findNodes(host)
126
+
127
+ if (candidates.length === 0) {
128
+ // Almost always a hostname or a gateway: the list holds bare IPs and a browser
129
+ // cannot resolve a name to one, so there is nothing the SDK can infer here.
130
+ throw nodeUnusable(
131
+ `${host} is not in the net actor list of the ledger it served ` +
132
+ `(${ledger.nodes.length} records, none at that address). A node is matched by ` +
133
+ 'its IP address, so a DNS name, a gateway or a proxy cannot be matched at all — ' +
134
+ `pass nodeNumber explicitly for those. ${listAlternatives(ledger)}`,
135
+ )
136
+ }
137
+
138
+ if (candidates.length === 1) return assertUsable(candidates[0] as GeeqLedgerNode, ledger)
139
+
140
+ const processing = candidates.filter((node) => node.processing && !node.retiring)
141
+ if (processing.length === 1) return processing[0] as GeeqLedgerNode
142
+
143
+ const numbers = candidates.map((node) => node.nodeNumber).join(', ')
144
+ throw nodeUnusable(
145
+ `${host} matches ${candidates.length} net actor records (node numbers ${numbers}) ` +
146
+ `and ${processing.length} of them are processing, so the SDK cannot choose. ` +
147
+ 'Pass nodeNumber explicitly.',
148
+ )
149
+ }
@@ -0,0 +1,83 @@
1
+ import { blake3 } from '@noble/hashes/blake3'
2
+ import { invalidArgument, invalidKey } from '../errors'
3
+
4
+ /** Account ids and public keys are both 32 bytes. */
5
+ export const ACCOUNT_ID_BYTES = 32
6
+ export const PUBLIC_KEY_BYTES = 32
7
+
8
+ /**
9
+ * Any way of naming an account that does not need the engine.
10
+ *
11
+ * Structural on purpose: a {@link GeeqKeypair} already carries `accountId`, so it
12
+ * satisfies this without the crypto layer importing the keypair type and without the
13
+ * caller unwrapping anything.
14
+ */
15
+ export type AccountRef =
16
+ | Uint8Array
17
+ | { readonly accountId: Uint8Array }
18
+ | { readonly publicKey: Uint8Array }
19
+
20
+ /**
21
+ * Derive the 32-byte account id for a public key.
22
+ *
23
+ * This is a pure hash — it needs no engine and no network, so it works before
24
+ * {@link initGeeq} has resolved.
25
+ */
26
+ export function accountIdFromPublicKey(publicKey: Uint8Array): Uint8Array {
27
+ if (publicKey.length !== PUBLIC_KEY_BYTES) {
28
+ throw invalidKey(
29
+ `A public key must be ${PUBLIC_KEY_BYTES} bytes, received ${publicKey.length}.`,
30
+ )
31
+ }
32
+ return blake3(publicKey, { dkLen: ACCOUNT_ID_BYTES })
33
+ }
34
+
35
+ /**
36
+ * Resolve any {@link AccountRef} to a 32-byte account id.
37
+ *
38
+ * Shared by the address layer and the ledger layer: both need to turn "whatever the
39
+ * caller had to hand" into the one value records are keyed by, and two copies of this
40
+ * would drift. Pure — no engine, no network.
41
+ *
42
+ * `accountId` is checked before `publicKey` so that a keypair, which carries both,
43
+ * takes the cheap path instead of re-hashing its own public key.
44
+ *
45
+ * @internal
46
+ */
47
+ export function resolveAccountId(account: AccountRef): Uint8Array {
48
+ if (account instanceof Uint8Array) {
49
+ if (account.length !== ACCOUNT_ID_BYTES) {
50
+ throw invalidKey(
51
+ `An account id must be ${ACCOUNT_ID_BYTES} bytes, received ${account.length}.`,
52
+ )
53
+ }
54
+ return account
55
+ }
56
+
57
+ if (account !== null && typeof account === 'object') {
58
+ const { accountId } = account as { accountId?: unknown }
59
+ if (accountId !== undefined) {
60
+ if (!(accountId instanceof Uint8Array)) {
61
+ throw invalidKey('accountId must be a Uint8Array.')
62
+ }
63
+ if (accountId.length !== ACCOUNT_ID_BYTES) {
64
+ throw invalidKey(
65
+ `An account id must be ${ACCOUNT_ID_BYTES} bytes, received ${accountId.length}.`,
66
+ )
67
+ }
68
+ return accountId
69
+ }
70
+
71
+ const { publicKey } = account as { publicKey?: unknown }
72
+ if (publicKey !== undefined) {
73
+ if (!(publicKey instanceof Uint8Array)) {
74
+ throw invalidKey('publicKey must be a Uint8Array.')
75
+ }
76
+ return accountIdFromPublicKey(publicKey)
77
+ }
78
+ }
79
+
80
+ throw invalidArgument(
81
+ `Provide a keypair, a ${ACCOUNT_ID_BYTES}-byte accountId, or a ${PUBLIC_KEY_BYTES}-byte publicKey.`,
82
+ )
83
+ }
@@ -0,0 +1,221 @@
1
+ import { toBase64 } from '../encoding/base64'
2
+ import { invalidArgument, invalidKey } from '../errors'
3
+ import { WasmKeypair, wasmVerify } from '../runtime/bindings'
4
+ import { assertReady, initGeeq } from '../runtime/init'
5
+ import { DEFAULT_RECORD_TYPE, toWasmCoinType, toWasmRecordType } from '../address/record-type'
6
+ import { createWalletAddressSync } from '../address/wallet-address'
7
+ import type {
8
+ GeeqKeypair,
9
+ MnemonicOptions,
10
+ WalletAddressOptions,
11
+ } from '../types'
12
+ import { PUBLIC_KEY_BYTES, accountIdFromPublicKey } from './account-id'
13
+ import { normalizeMnemonic } from './mnemonic'
14
+
15
+ const SECRET_KEY_BYTES = 32
16
+ const SIGNATURE_BYTES = 64
17
+
18
+ /**
19
+ * Wraps an engine-side keypair. The byte fields are copied out once at
20
+ * construction so the object stays fully readable after {@link dispose}, which
21
+ * only releases the engine handle used for signing.
22
+ */
23
+ class Keypair implements GeeqKeypair {
24
+ readonly publicKey: Uint8Array
25
+ readonly privateKey: Uint8Array
26
+ readonly accountId: Uint8Array
27
+
28
+ /**
29
+ * The engine-side handle.
30
+ *
31
+ * Deliberately a plain non-enumerable property rather than a `#private` field:
32
+ * private fields are unreachable through a Proxy, so a keypair placed in
33
+ * proxy-based reactive state — Vue's `ref`/`reactive`, MobX, Valtio — would
34
+ * throw `TypeError: Cannot read from private field` on every method call.
35
+ * Non-enumerable keeps it out of spreads, `Object.keys` and devtools noise.
36
+ */
37
+ private handle: WasmKeypair | null
38
+
39
+ constructor(inner: WasmKeypair) {
40
+ this.handle = inner
41
+ Object.defineProperty(this, 'handle', { enumerable: false })
42
+ this.publicKey = inner.exportPublicKey()
43
+ this.privateKey = inner.exportSecretKey()
44
+ this.accountId = accountIdFromPublicKey(this.publicKey)
45
+ }
46
+
47
+ getAddress(options: WalletAddressOptions): string {
48
+ return createWalletAddressSync({
49
+ accountId: this.accountId,
50
+ chainNumber: options.chainNumber,
51
+ recordType: options.recordType ?? DEFAULT_RECORD_TYPE,
52
+ })
53
+ }
54
+
55
+ sign(message: Uint8Array): Uint8Array {
56
+ if (this.handle === null) {
57
+ throw invalidKey('This keypair has been disposed and can no longer sign.')
58
+ }
59
+ if (!(message instanceof Uint8Array)) {
60
+ throw invalidArgument('sign() expects a Uint8Array.')
61
+ }
62
+ return this.handle.sign(message)
63
+ }
64
+
65
+ dispose(): void {
66
+ this.handle?.free()
67
+ this.handle = null
68
+ }
69
+
70
+ /** Keeps secret material out of logs, `console.log` and error breadcrumbs. */
71
+ toJSON(): { readonly type: 'GeeqKeypair'; readonly publicKey: string } {
72
+ return { type: 'GeeqKeypair', publicKey: toBase64(this.publicKey) }
73
+ }
74
+
75
+ toString(): string {
76
+ return '[GeeqKeypair]'
77
+ }
78
+ }
79
+
80
+ function assertBytes(value: unknown, expected: number, label: string): Uint8Array {
81
+ if (!(value instanceof Uint8Array)) {
82
+ throw invalidKey(`${label} must be a Uint8Array.`)
83
+ }
84
+ if (value.length !== expected) {
85
+ throw invalidKey(`${label} must be ${expected} bytes, received ${value.length}.`)
86
+ }
87
+ return value
88
+ }
89
+
90
+ /** Create a brand new random keypair. Synchronous; requires a loaded engine. */
91
+ export function generateKeypairSync(): GeeqKeypair {
92
+ assertReady('generateKeypairSync')
93
+ return new Keypair(WasmKeypair.generate())
94
+ }
95
+
96
+ /** Create a brand new random keypair, loading the engine if needed. */
97
+ export async function generateKeypair(): Promise<GeeqKeypair> {
98
+ await initGeeq()
99
+ return generateKeypairSync()
100
+ }
101
+
102
+ /** Re-create a keypair from stored public and private key bytes. */
103
+ export function importKeypairSync(publicKey: Uint8Array, privateKey: Uint8Array): GeeqKeypair {
104
+ assertReady('importKeypairSync')
105
+ assertBytes(publicKey, PUBLIC_KEY_BYTES, 'publicKey')
106
+ assertBytes(privateKey, SECRET_KEY_BYTES, 'privateKey')
107
+
108
+ let keypair: Keypair
109
+ try {
110
+ // Deliberately derived from the secret key rather than via the engine's
111
+ // import(), which accepts a mismatched pair and stores the given public key
112
+ // verbatim — yielding a keypair whose signatures the chain would reject.
113
+ keypair = new Keypair(WasmKeypair.fromSecretKey(privateKey))
114
+ } catch (cause) {
115
+ throw invalidKey('Could not import this keypair. Check the private key is correct.', cause)
116
+ }
117
+
118
+ if (!bytesEqual(keypair.publicKey, publicKey)) {
119
+ keypair.dispose()
120
+ throw invalidKey(
121
+ 'publicKey does not match privateKey. Check the two halves came from the same keypair.',
122
+ )
123
+ }
124
+ return keypair
125
+ }
126
+
127
+ /** Re-create a keypair from stored key bytes, loading the engine if needed. */
128
+ export async function importKeypair(
129
+ publicKey: Uint8Array,
130
+ privateKey: Uint8Array,
131
+ ): Promise<GeeqKeypair> {
132
+ await initGeeq()
133
+ return importKeypairSync(publicKey, privateKey)
134
+ }
135
+
136
+ /** Recover a keypair from its 32-byte secret key alone. */
137
+ export function keypairFromSecretKeySync(privateKey: Uint8Array): GeeqKeypair {
138
+ assertReady('keypairFromSecretKeySync')
139
+ assertBytes(privateKey, SECRET_KEY_BYTES, 'privateKey')
140
+ try {
141
+ return new Keypair(WasmKeypair.fromSecretKey(privateKey))
142
+ } catch (cause) {
143
+ throw invalidKey('Could not derive a keypair from this secret key.', cause)
144
+ }
145
+ }
146
+
147
+ /** Recover a keypair from its secret key, loading the engine if needed. */
148
+ export async function keypairFromSecretKey(privateKey: Uint8Array): Promise<GeeqKeypair> {
149
+ await initGeeq()
150
+ return keypairFromSecretKeySync(privateKey)
151
+ }
152
+
153
+ /**
154
+ * Derive a keypair from a BIP-39 mnemonic along Geeq's standard derivation path.
155
+ *
156
+ * The same mnemonic and options always produce the same keypair, so this is how a
157
+ * wallet is restored on another device.
158
+ */
159
+ export function keypairFromMnemonicSync(
160
+ mnemonic: string,
161
+ options: MnemonicOptions = {},
162
+ ): GeeqKeypair {
163
+ assertReady('keypairFromMnemonicSync')
164
+
165
+ if (typeof mnemonic !== 'string' || mnemonic.trim().length === 0) {
166
+ throw invalidArgument('mnemonic must be a non-empty BIP-39 phrase.')
167
+ }
168
+ const account = options.account ?? 0
169
+ const addressIndex = options.addressIndex ?? 0
170
+ for (const [label, value] of [['account', account], ['addressIndex', addressIndex]] as const) {
171
+ if (!Number.isInteger(value) || value < 0 || value > 0x7fffffff) {
172
+ throw invalidArgument(`${label} must be an integer between 0 and 2147483647.`)
173
+ }
174
+ }
175
+
176
+ try {
177
+ return new Keypair(
178
+ WasmKeypair.tryFromMnemonicGeeqPath(
179
+ normalizeMnemonic(mnemonic),
180
+ options.passphrase ?? '',
181
+ toWasmRecordType(options.recordType ?? DEFAULT_RECORD_TYPE),
182
+ toWasmCoinType(options.coinType ?? 'mainnet'),
183
+ account,
184
+ addressIndex,
185
+ ),
186
+ )
187
+ } catch (cause) {
188
+ throw invalidKey(
189
+ 'Could not derive a keypair from this mnemonic. Check the words and the passphrase.',
190
+ cause,
191
+ )
192
+ }
193
+ }
194
+
195
+ /** Derive a keypair from a mnemonic, loading the engine if needed. */
196
+ export async function keypairFromMnemonic(
197
+ mnemonic: string,
198
+ options: MnemonicOptions = {},
199
+ ): Promise<GeeqKeypair> {
200
+ await initGeeq()
201
+ return keypairFromMnemonicSync(mnemonic, options)
202
+ }
203
+
204
+ /** Verify an Ed25519 signature. Requires a loaded engine. */
205
+ export function verifySignature(
206
+ publicKey: Uint8Array,
207
+ signature: Uint8Array,
208
+ message: Uint8Array,
209
+ ): boolean {
210
+ assertReady('verifySignature')
211
+ assertBytes(publicKey, PUBLIC_KEY_BYTES, 'publicKey')
212
+ assertBytes(signature, SIGNATURE_BYTES, 'signature')
213
+ return wasmVerify(publicKey, signature, message)
214
+ }
215
+
216
+ function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
217
+ if (a.length !== b.length) return false
218
+ let diff = 0
219
+ for (let i = 0; i < a.length; i += 1) diff |= (a[i] as number) ^ (b[i] as number)
220
+ return diff === 0
221
+ }