@safe-vanity-blockie/core 0.3.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/LICENSE +21 -0
- package/README.md +49 -0
- package/dist/address.d.ts +26 -0
- package/dist/address.d.ts.map +1 -0
- package/dist/address.js +61 -0
- package/dist/address.js.map +1 -0
- package/dist/blo.d.ts +15 -0
- package/dist/blo.d.ts.map +1 -0
- package/dist/blo.js +86 -0
- package/dist/blo.js.map +1 -0
- package/dist/hex.d.ts +4 -0
- package/dist/hex.d.ts.map +1 -0
- package/dist/hex.js +26 -0
- package/dist/hex.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/keccak.d.ts +8 -0
- package/dist/keccak.d.ts.map +1 -0
- package/dist/keccak.js +15 -0
- package/dist/keccak.js.map +1 -0
- package/dist/miner.d.ts +45 -0
- package/dist/miner.d.ts.map +1 -0
- package/dist/miner.js +97 -0
- package/dist/miner.js.map +1 -0
- package/dist/scoring.d.ts +27 -0
- package/dist/scoring.d.ts.map +1 -0
- package/dist/scoring.js +185 -0
- package/dist/scoring.js.map +1 -0
- package/dist/select.d.ts +47 -0
- package/dist/select.d.ts.map +1 -0
- package/dist/select.js +49 -0
- package/dist/select.js.map +1 -0
- package/dist/templates.d.ts +18 -0
- package/dist/templates.d.ts.map +1 -0
- package/dist/templates.js +140 -0
- package/dist/templates.js.map +1 -0
- package/dist/types.d.ts +60 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +52 -0
- package/src/address.ts +89 -0
- package/src/blo.ts +100 -0
- package/src/hex.ts +25 -0
- package/src/index.ts +60 -0
- package/src/keccak.ts +17 -0
- package/src/miner.ts +133 -0
- package/src/scoring.ts +209 -0
- package/src/select.ts +77 -0
- package/src/templates.ts +163 -0
- package/src/types.ts +66 -0
package/src/address.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { bytesToHex } from './hex.js'
|
|
2
|
+
import type { Keccak256 } from './keccak.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The three values that are constant for a given (owners, threshold, safeVersion) and therefore
|
|
6
|
+
* precomputed once on the main thread. Only the saltNonce varies per iteration.
|
|
7
|
+
*/
|
|
8
|
+
export interface SafeConstants {
|
|
9
|
+
/** keccak256 of the ABI-encoded setup() calldata. 32 bytes. */
|
|
10
|
+
readonly initializerHash: Uint8Array
|
|
11
|
+
/** SafeProxyFactory address. 20 bytes. */
|
|
12
|
+
readonly factory: Uint8Array
|
|
13
|
+
/** keccak256(proxyCreationCode ++ abi.encode(address, singleton)). 32 bytes. */
|
|
14
|
+
readonly initCodeHash: Uint8Array
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface AddressDeriver {
|
|
18
|
+
/** Fast path for saltNonce < 2^53. Returns a lowercase 0x address. */
|
|
19
|
+
derive(saltNonce: number): string
|
|
20
|
+
/** Full uint256 path, for verification and huge nonces. Returns a lowercase 0x address. */
|
|
21
|
+
deriveBig(saltNonce: bigint): string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const MAX_UINT256 = (1n << 256n) - 1n
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Builds a deriver whose buffers are allocated once. Each derive() is exactly two keccaks:
|
|
28
|
+
* salt = keccak256(initializerHash ++ uint256(saltNonce))
|
|
29
|
+
* address = keccak256(0xff ++ factory ++ salt ++ initCodeHash)[12..32)
|
|
30
|
+
*/
|
|
31
|
+
export function createAddressDeriver(
|
|
32
|
+
constants: SafeConstants,
|
|
33
|
+
keccak256: Keccak256,
|
|
34
|
+
): AddressDeriver {
|
|
35
|
+
const { initializerHash, factory, initCodeHash } = constants
|
|
36
|
+
if (initializerHash.length !== 32) {
|
|
37
|
+
throw new Error(`initializerHash must be 32 bytes, got ${initializerHash.length}`)
|
|
38
|
+
}
|
|
39
|
+
if (factory.length !== 20) throw new Error(`factory must be 20 bytes, got ${factory.length}`)
|
|
40
|
+
if (initCodeHash.length !== 32) {
|
|
41
|
+
throw new Error(`initCodeHash must be 32 bytes, got ${initCodeHash.length}`)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Bytes 32..64 are the big-endian uint256 saltNonce. Bytes 32..56 stay zero for derive(),
|
|
45
|
+
// so the fast path only writes the low 8 bytes.
|
|
46
|
+
const saltPreimage = new Uint8Array(64)
|
|
47
|
+
saltPreimage.set(initializerHash, 0)
|
|
48
|
+
const saltView = new DataView(saltPreimage.buffer)
|
|
49
|
+
|
|
50
|
+
const create2Preimage = new Uint8Array(85)
|
|
51
|
+
create2Preimage[0] = 0xff
|
|
52
|
+
create2Preimage.set(factory, 1)
|
|
53
|
+
create2Preimage.set(initCodeHash, 53)
|
|
54
|
+
|
|
55
|
+
function finish(): string {
|
|
56
|
+
const salt = keccak256(saltPreimage)
|
|
57
|
+
create2Preimage.set(salt, 21)
|
|
58
|
+
return bytesToHex(keccak256(create2Preimage), 12, 32)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
derive(saltNonce: number): string {
|
|
63
|
+
if (!Number.isSafeInteger(saltNonce) || saltNonce < 0) {
|
|
64
|
+
throw new Error(`derive() needs a non-negative safe integer, got ${saltNonce}`)
|
|
65
|
+
}
|
|
66
|
+
const high = Math.floor(saltNonce / 4294967296)
|
|
67
|
+
saltView.setUint32(56, high)
|
|
68
|
+
saltView.setUint32(60, saltNonce - high * 4294967296)
|
|
69
|
+
return finish()
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
deriveBig(saltNonce: bigint): string {
|
|
73
|
+
if (saltNonce < 0n || saltNonce > MAX_UINT256) {
|
|
74
|
+
throw new Error(`deriveBig() needs a uint256, got ${saltNonce}`)
|
|
75
|
+
}
|
|
76
|
+
let remaining = saltNonce
|
|
77
|
+
for (let i = 63; i >= 32; i--) {
|
|
78
|
+
saltPreimage[i] = Number(remaining & 0xffn)
|
|
79
|
+
remaining >>= 8n
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
return finish()
|
|
83
|
+
} finally {
|
|
84
|
+
// Restore the invariant derive() relies on: bytes 32..56 are zero.
|
|
85
|
+
saltPreimage.fill(0, 32, 56)
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
}
|
package/src/blo.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { BloImage, Hsl, Palette } from './types.js'
|
|
2
|
+
|
|
3
|
+
/** = 1 / 2147483648. Parenthesise as ((1 << 31) >>> 0); `1 << (31 >>> 0)` is negative. */
|
|
4
|
+
const RANDOM_SCALE = 1 / ((1 << 31) >>> 0)
|
|
5
|
+
|
|
6
|
+
/** Number of nextRandom() draws consumed by the three randomColor() calls before the grid. */
|
|
7
|
+
const COLOR_DRAWS = 18
|
|
8
|
+
|
|
9
|
+
export function seedInto(seed: string, rseed: Uint32Array): void {
|
|
10
|
+
rseed[0] = 0
|
|
11
|
+
rseed[1] = 0
|
|
12
|
+
rseed[2] = 0
|
|
13
|
+
rseed[3] = 0
|
|
14
|
+
for (let i = 0; i < seed.length; i++) {
|
|
15
|
+
const slot = i & 3
|
|
16
|
+
rseed[slot] = (rseed[slot] << 5) - rseed[slot] + seed.charCodeAt(i)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function randSeed(seed: string): Uint32Array {
|
|
21
|
+
const rseed = new Uint32Array(4)
|
|
22
|
+
seedInto(seed, rseed)
|
|
23
|
+
return rseed
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function nextRandom(rseed: Uint32Array): number {
|
|
27
|
+
const t = rseed[0] ^ (rseed[0] << 11)
|
|
28
|
+
rseed[0] = rseed[1]
|
|
29
|
+
rseed[1] = rseed[2]
|
|
30
|
+
rseed[2] = rseed[3]
|
|
31
|
+
rseed[3] = (rseed[3] ^ (rseed[3] >> 19) ^ t ^ (t >> 8)) >>> 0
|
|
32
|
+
return rseed[3] * RANDOM_SCALE
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function randomColor(rseed: Uint32Array): Hsl {
|
|
36
|
+
return [
|
|
37
|
+
Math.floor(nextRandom(rseed) * 360),
|
|
38
|
+
Math.floor(40 + nextRandom(rseed) * 60),
|
|
39
|
+
Math.floor(
|
|
40
|
+
(nextRandom(rseed) + nextRandom(rseed) + nextRandom(rseed) + nextRandom(rseed)) * 25,
|
|
41
|
+
),
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function bloImage(address: string): BloImage {
|
|
46
|
+
const rseed = randSeed(address.toLowerCase())
|
|
47
|
+
// blo assigns these to c, b, s in this order but returns them as [b, c, s].
|
|
48
|
+
const c = randomColor(rseed)
|
|
49
|
+
const b = randomColor(rseed)
|
|
50
|
+
const s = randomColor(rseed)
|
|
51
|
+
const data = new Uint8Array(32)
|
|
52
|
+
for (let i = 0; i < 32; i++) data[i] = Math.floor(nextRandom(rseed) * 2.3)
|
|
53
|
+
return { data, colors: [b, c, s] as Palette }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Hot-path grid generation. `lowercaseAddress` MUST already be lowercased and 0x-prefixed —
|
|
58
|
+
* the caller owns that so the loop never allocates a string. Writes 32 values into `data`
|
|
59
|
+
* and reuses `rseed` as scratch. Identical output to `bloImage(address).data`.
|
|
60
|
+
*/
|
|
61
|
+
export function bloDataInto(lowercaseAddress: string, data: Uint8Array, rseed: Uint32Array): void {
|
|
62
|
+
seedInto(lowercaseAddress, rseed)
|
|
63
|
+
for (let i = 0; i < COLOR_DRAWS; i++) nextRandom(rseed)
|
|
64
|
+
for (let i = 0; i < 32; i++) data[i] = Math.floor(nextRandom(rseed) * 2.3)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function bloData(address: string): Uint8Array {
|
|
68
|
+
const data = new Uint8Array(32)
|
|
69
|
+
bloDataInto(address.toLowerCase(), data, new Uint32Array(4))
|
|
70
|
+
return data
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function bloSvg(address: string, size = 64): string {
|
|
74
|
+
const {
|
|
75
|
+
data,
|
|
76
|
+
colors: [b, c, s],
|
|
77
|
+
} = bloImage(address)
|
|
78
|
+
const paths = ['', '']
|
|
79
|
+
for (let i = 0; i < 32; i++) {
|
|
80
|
+
if (data[i] === 0) continue
|
|
81
|
+
const x = i & 3
|
|
82
|
+
const y = i >> 2
|
|
83
|
+
const square = ',' + y + 'h1v1h-1z'
|
|
84
|
+
paths[data[i] - 1] += 'M' + x + square + 'M' + (7 - x) + square
|
|
85
|
+
}
|
|
86
|
+
const path = (color: Hsl, d: string) =>
|
|
87
|
+
'<path fill="hsl(' + color[0] + ' ' + color[1] + '% ' + color[2] + '%)" d="' + d + '"/>'
|
|
88
|
+
return (
|
|
89
|
+
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8" shape-rendering="optimizeSpeed" ' +
|
|
90
|
+
'width="' +
|
|
91
|
+
size +
|
|
92
|
+
'" height="' +
|
|
93
|
+
size +
|
|
94
|
+
'">' +
|
|
95
|
+
path(b, 'M0,0H8V8H0z') +
|
|
96
|
+
path(c, paths[0]) +
|
|
97
|
+
path(s, paths[1]) +
|
|
98
|
+
'</svg>'
|
|
99
|
+
)
|
|
100
|
+
}
|
package/src/hex.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const HEX_BYTES = Array.from({ length: 256 }, (_, i) => i.toString(16).padStart(2, '0'))
|
|
2
|
+
|
|
3
|
+
/** Lowercase 0x-prefixed hex for `bytes[start..end)`. */
|
|
4
|
+
export function bytesToHex(bytes: Uint8Array, start = 0, end = bytes.length): string {
|
|
5
|
+
let out = '0x'
|
|
6
|
+
for (let i = start; i < end; i++) out += HEX_BYTES[bytes[i]]
|
|
7
|
+
return out
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function hexToBytes(hex: string): Uint8Array {
|
|
11
|
+
const body = hex.startsWith('0x') || hex.startsWith('0X') ? hex.slice(2) : hex
|
|
12
|
+
if (body.length % 2 !== 0) {
|
|
13
|
+
throw new Error(`hexToBytes: odd-length hex string (${body.length} characters)`)
|
|
14
|
+
}
|
|
15
|
+
if (!/^[0-9a-fA-F]*$/.test(body)) {
|
|
16
|
+
throw new Error(`hexToBytes: invalid hex characters`)
|
|
17
|
+
}
|
|
18
|
+
const out = new Uint8Array(body.length / 2)
|
|
19
|
+
for (let i = 0; i < out.length; i++) {
|
|
20
|
+
const byte = Number.parseInt(body.slice(i * 2, i * 2 + 2), 16)
|
|
21
|
+
if (Number.isNaN(byte)) throw new Error(`hexToBytes: invalid hex at byte ${i}`)
|
|
22
|
+
out[i] = byte
|
|
23
|
+
}
|
|
24
|
+
return out
|
|
25
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export { type AddressDeriver, createAddressDeriver, type SafeConstants } from './address.js'
|
|
2
|
+
export {
|
|
3
|
+
bloData,
|
|
4
|
+
bloDataInto,
|
|
5
|
+
bloImage,
|
|
6
|
+
bloSvg,
|
|
7
|
+
nextRandom,
|
|
8
|
+
randomColor,
|
|
9
|
+
randSeed,
|
|
10
|
+
seedInto,
|
|
11
|
+
} from './blo.js'
|
|
12
|
+
export { bytesToHex, hexToBytes } from './hex.js'
|
|
13
|
+
export { createKeccak256, type Keccak256 } from './keccak.js'
|
|
14
|
+
export {
|
|
15
|
+
type Candidate,
|
|
16
|
+
compareCandidates,
|
|
17
|
+
createMiner,
|
|
18
|
+
Leaderboard,
|
|
19
|
+
type MineOptions,
|
|
20
|
+
type MineResult,
|
|
21
|
+
} from './miner.js'
|
|
22
|
+
export {
|
|
23
|
+
apportion,
|
|
24
|
+
colorContrast,
|
|
25
|
+
compileFace,
|
|
26
|
+
describeMatch,
|
|
27
|
+
hslToRgb,
|
|
28
|
+
isTwoColor,
|
|
29
|
+
makeScorer,
|
|
30
|
+
} from './scoring.js'
|
|
31
|
+
export {
|
|
32
|
+
filterCandidates,
|
|
33
|
+
formatScore,
|
|
34
|
+
type SelectReportedResult,
|
|
35
|
+
scorePercent,
|
|
36
|
+
selectReported,
|
|
37
|
+
} from './select.js'
|
|
38
|
+
export {
|
|
39
|
+
BASE_TARGET,
|
|
40
|
+
BASE_WEIGHTS,
|
|
41
|
+
faceWithMouths,
|
|
42
|
+
getTemplate,
|
|
43
|
+
MOUTH_BG_WEIGHT,
|
|
44
|
+
MOUTH_BUDGET,
|
|
45
|
+
MOUTH_INDICES,
|
|
46
|
+
MOUTH_STROKE_WEIGHT,
|
|
47
|
+
MOUTHS,
|
|
48
|
+
parseFaceSpec,
|
|
49
|
+
TEMPLATES,
|
|
50
|
+
} from './templates.js'
|
|
51
|
+
export type {
|
|
52
|
+
BloImage,
|
|
53
|
+
CompiledFace,
|
|
54
|
+
FaceRegion,
|
|
55
|
+
FaceSpec,
|
|
56
|
+
FixedCell,
|
|
57
|
+
Hsl,
|
|
58
|
+
Palette,
|
|
59
|
+
RegionAlternative,
|
|
60
|
+
} from './types.js'
|
package/src/keccak.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { createKeccak } from 'hash-wasm'
|
|
2
|
+
|
|
3
|
+
export type Keccak256 = (input: Uint8Array) => Uint8Array
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Creates a keccak-256 function backed by WebAssembly. Real WASM, so the identical module works
|
|
7
|
+
* in Node worker_threads and browser Web Workers. Call once per worker at startup — instantiation
|
|
8
|
+
* is the expensive part, hashing afterwards is not.
|
|
9
|
+
*/
|
|
10
|
+
export async function createKeccak256(): Promise<Keccak256> {
|
|
11
|
+
const hasher = await createKeccak(256)
|
|
12
|
+
return (input: Uint8Array): Uint8Array => {
|
|
13
|
+
hasher.init()
|
|
14
|
+
hasher.update(input)
|
|
15
|
+
return hasher.digest('binary')
|
|
16
|
+
}
|
|
17
|
+
}
|
package/src/miner.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { createAddressDeriver, type SafeConstants } from './address.js'
|
|
2
|
+
import { bloDataInto, bloImage } from './blo.js'
|
|
3
|
+
import type { Keccak256 } from './keccak.js'
|
|
4
|
+
import { colorContrast, describeMatch, isTwoColor, makeScorer } from './scoring.js'
|
|
5
|
+
import type { CompiledFace } from './types.js'
|
|
6
|
+
|
|
7
|
+
export interface Candidate {
|
|
8
|
+
/** Decimal string: a saltNonce is a uint256 and may exceed 2^53. */
|
|
9
|
+
saltNonce: string
|
|
10
|
+
address: string
|
|
11
|
+
score: number
|
|
12
|
+
maxScore: number
|
|
13
|
+
twoColor: boolean
|
|
14
|
+
contrast: number
|
|
15
|
+
/** Winning alternative per region, e.g. `{ mouth: 'smile' }`. */
|
|
16
|
+
regions: Record<string, string>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Ranking from spec §5.6: score desc, two-colour first, contrast desc, then saltNonce for stability. */
|
|
20
|
+
export function compareCandidates(a: Candidate, b: Candidate): number {
|
|
21
|
+
if (a.score !== b.score) return b.score - a.score
|
|
22
|
+
if (a.twoColor !== b.twoColor) return a.twoColor ? -1 : 1
|
|
23
|
+
if (a.contrast !== b.contrast) return b.contrast - a.contrast
|
|
24
|
+
if (a.saltNonce === b.saltNonce) return 0
|
|
25
|
+
return a.saltNonce.length - b.saltNonce.length || (a.saltNonce < b.saltNonce ? -1 : 1)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class Leaderboard {
|
|
29
|
+
readonly capacity: number
|
|
30
|
+
private items: Candidate[] = []
|
|
31
|
+
private seen = new Set<string>()
|
|
32
|
+
|
|
33
|
+
constructor(capacity: number) {
|
|
34
|
+
if (!Number.isInteger(capacity) || capacity < 1) {
|
|
35
|
+
throw new Error(`Leaderboard capacity must be a positive integer, got ${capacity}`)
|
|
36
|
+
}
|
|
37
|
+
this.capacity = capacity
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Score a candidate must reach to be worth building. -1 until the board is full. */
|
|
41
|
+
get threshold(): number {
|
|
42
|
+
return this.items.length < this.capacity ? -1 : this.items[this.items.length - 1].score
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
offer(candidate: Candidate): boolean {
|
|
46
|
+
if (this.seen.has(candidate.address)) return false
|
|
47
|
+
if (this.items.length >= this.capacity && candidate.score < this.threshold) return false
|
|
48
|
+
this.seen.add(candidate.address)
|
|
49
|
+
this.items.push(candidate)
|
|
50
|
+
this.items.sort(compareCandidates)
|
|
51
|
+
if (this.items.length > this.capacity) this.items.length = this.capacity
|
|
52
|
+
this.seen = new Set(this.items.map((item) => item.address))
|
|
53
|
+
return true
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
merge(candidates: Candidate[]): void {
|
|
57
|
+
for (const candidate of candidates) this.offer(candidate)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
entries(): Candidate[] {
|
|
61
|
+
return this.items.slice()
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface MineOptions {
|
|
66
|
+
start: number
|
|
67
|
+
count: number
|
|
68
|
+
/** Leaderboard size. Default 20. */
|
|
69
|
+
keep?: number
|
|
70
|
+
/** Iterations between onProgress callbacks. Default 250_000. */
|
|
71
|
+
chunkSize?: number
|
|
72
|
+
/** Return false to stop early. `scanned` is cumulative for this call. */
|
|
73
|
+
// biome-ignore lint/suspicious/noConfusingVoidType: deliberate union with void so callers may return nothing; narrowing to `boolean | undefined` breaks consumers of the published package whose callback bodies infer a `void` return.
|
|
74
|
+
onProgress?: (scanned: number, best: Candidate[]) => boolean | void
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface MineResult {
|
|
78
|
+
scanned: number
|
|
79
|
+
candidates: Candidate[]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function createMiner(
|
|
83
|
+
constants: SafeConstants,
|
|
84
|
+
face: CompiledFace,
|
|
85
|
+
keccak256: Keccak256,
|
|
86
|
+
): { mine(options: MineOptions): MineResult } {
|
|
87
|
+
const deriver = createAddressDeriver(constants, keccak256)
|
|
88
|
+
const score = makeScorer(face)
|
|
89
|
+
|
|
90
|
+
/** Off the hot path — only runs when a candidate reaches the leaderboard. */
|
|
91
|
+
function buildCandidate(nonce: number, address: string, value: number): Candidate {
|
|
92
|
+
const { data, colors } = bloImage(address)
|
|
93
|
+
return {
|
|
94
|
+
saltNonce: String(nonce),
|
|
95
|
+
address,
|
|
96
|
+
score: value,
|
|
97
|
+
maxScore: face.maxScore,
|
|
98
|
+
twoColor: isTwoColor(data),
|
|
99
|
+
contrast: Math.round(colorContrast(colors[0], colors[1])),
|
|
100
|
+
regions: describeMatch(face, data).regions,
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
mine(options: MineOptions): MineResult {
|
|
106
|
+
const chunkSize = options.chunkSize ?? 250_000
|
|
107
|
+
if (!Number.isInteger(chunkSize) || chunkSize <= 0) {
|
|
108
|
+
throw new Error(`chunkSize must be a positive integer, got ${chunkSize}`)
|
|
109
|
+
}
|
|
110
|
+
// keep is validated by the Leaderboard constructor below.
|
|
111
|
+
const board = new Leaderboard(options.keep ?? 20)
|
|
112
|
+
// Allocated once for the whole run; the hot loop allocates nothing else.
|
|
113
|
+
const data = new Uint8Array(32)
|
|
114
|
+
const rseed = new Uint32Array(4)
|
|
115
|
+
let scanned = 0
|
|
116
|
+
|
|
117
|
+
while (scanned < options.count) {
|
|
118
|
+
const from = options.start + scanned
|
|
119
|
+
const to = from + Math.min(chunkSize, options.count - scanned)
|
|
120
|
+
for (let nonce = from; nonce < to; nonce++) {
|
|
121
|
+
const address = deriver.derive(nonce)
|
|
122
|
+
bloDataInto(address, data, rseed)
|
|
123
|
+
const value = score(data)
|
|
124
|
+
if (value >= board.threshold) board.offer(buildCandidate(nonce, address, value))
|
|
125
|
+
}
|
|
126
|
+
scanned = to - options.start
|
|
127
|
+
if (options.onProgress?.(scanned, board.entries()) === false) break
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return { scanned, candidates: board.entries() }
|
|
131
|
+
},
|
|
132
|
+
}
|
|
133
|
+
}
|
package/src/scoring.ts
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import type { CompiledFace, FaceSpec, Hsl } from './types.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Largest-remainder apportionment: split `budget` across `rawWeights` proportionally using
|
|
5
|
+
* integers that sum to exactly `budget`.
|
|
6
|
+
*
|
|
7
|
+
* The spec's `Math.round(raw / total * budget)` does not do this — it yields 57 for a 3-stroke
|
|
8
|
+
* mouth and 62 for a 2-stroke one, silently making some expressions worth more than others.
|
|
9
|
+
*/
|
|
10
|
+
export function apportion(rawWeights: number[], budget: number): number[] {
|
|
11
|
+
if (rawWeights.length === 0) return []
|
|
12
|
+
const total = rawWeights.reduce((a, b) => a + b, 0)
|
|
13
|
+
if (total <= 0) throw new Error('apportion: raw weights must sum to a positive number')
|
|
14
|
+
const exact = rawWeights.map((weight) => (weight * budget) / total)
|
|
15
|
+
const shares = exact.map((value) => Math.floor(value))
|
|
16
|
+
const remainder = budget - shares.reduce((a, b) => a + b, 0)
|
|
17
|
+
const order = exact
|
|
18
|
+
.map((value, index) => ({ index, fraction: value - Math.floor(value) }))
|
|
19
|
+
.sort((a, b) => b.fraction - a.fraction || a.index - b.index)
|
|
20
|
+
for (let i = 0; i < remainder; i++) shares[order[i].index] += 1
|
|
21
|
+
return shares
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function compileFace(spec: FaceSpec): CompiledFace {
|
|
25
|
+
const used = new Set<number>()
|
|
26
|
+
const claim = (index: number, where: string) => {
|
|
27
|
+
if (!Number.isInteger(index) || index < 0 || index > 31) {
|
|
28
|
+
throw new Error(`${where}: cell index ${index} must be an integer between 0 and 31`)
|
|
29
|
+
}
|
|
30
|
+
if (used.has(index)) throw new Error(`${where}: cell index ${index} is used more than once`)
|
|
31
|
+
used.add(index)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const fixedIndex = new Uint8Array(spec.fixed.length)
|
|
35
|
+
const fixedTarget = new Uint8Array(spec.fixed.length)
|
|
36
|
+
const fixedWeight = new Int32Array(spec.fixed.length)
|
|
37
|
+
let maxScore = 0
|
|
38
|
+
spec.fixed.forEach((cell, i) => {
|
|
39
|
+
claim(cell.index, `fixed cell ${i}`)
|
|
40
|
+
fixedIndex[i] = cell.index
|
|
41
|
+
fixedTarget[i] = cell.value
|
|
42
|
+
fixedWeight[i] = cell.weight
|
|
43
|
+
maxScore += cell.weight
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const regionIndexStart = new Int32Array(spec.regions.length)
|
|
47
|
+
const regionLength = new Int32Array(spec.regions.length)
|
|
48
|
+
const regionAltCount = new Int32Array(spec.regions.length)
|
|
49
|
+
const regionCellStart = new Int32Array(spec.regions.length)
|
|
50
|
+
const indices: number[] = []
|
|
51
|
+
const cells: number[] = []
|
|
52
|
+
const weights: number[] = []
|
|
53
|
+
const regionNames: string[] = []
|
|
54
|
+
const regionAltNames: string[][] = []
|
|
55
|
+
|
|
56
|
+
spec.regions.forEach((region, r) => {
|
|
57
|
+
regionIndexStart[r] = indices.length
|
|
58
|
+
regionCellStart[r] = cells.length
|
|
59
|
+
regionLength[r] = region.indices.length
|
|
60
|
+
regionAltCount[r] = region.alternatives.length
|
|
61
|
+
regionNames.push(region.name)
|
|
62
|
+
regionAltNames.push(region.alternatives.map((alternative) => alternative.name))
|
|
63
|
+
|
|
64
|
+
if (region.alternatives.length === 0) {
|
|
65
|
+
throw new Error(`region "${region.name}": needs at least one alternative`)
|
|
66
|
+
}
|
|
67
|
+
for (const index of region.indices) claim(index, `region "${region.name}"`)
|
|
68
|
+
indices.push(...region.indices)
|
|
69
|
+
|
|
70
|
+
for (const alternative of region.alternatives) {
|
|
71
|
+
if (alternative.cells.length !== region.indices.length) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`region "${region.name}", alternative "${alternative.name}": expected ` +
|
|
74
|
+
`${region.indices.length} cells, got ${alternative.cells.length}`,
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
const raw = alternative.cells.map((cell) =>
|
|
78
|
+
cell === 1 ? region.strokeWeight : region.bgWeight,
|
|
79
|
+
)
|
|
80
|
+
cells.push(...alternative.cells)
|
|
81
|
+
weights.push(...apportion(raw, region.budget))
|
|
82
|
+
}
|
|
83
|
+
maxScore += region.budget
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
name: spec.name,
|
|
88
|
+
maxScore,
|
|
89
|
+
nFixed: spec.fixed.length,
|
|
90
|
+
fixedIndex,
|
|
91
|
+
fixedTarget,
|
|
92
|
+
fixedWeight,
|
|
93
|
+
nRegions: spec.regions.length,
|
|
94
|
+
regionIndexStart,
|
|
95
|
+
regionLength,
|
|
96
|
+
regionAltCount,
|
|
97
|
+
regionCellStart,
|
|
98
|
+
regionIndex: Uint8Array.from(indices),
|
|
99
|
+
regionCells: Uint8Array.from(cells),
|
|
100
|
+
regionWeights: Int32Array.from(weights),
|
|
101
|
+
regionNames,
|
|
102
|
+
regionAltNames,
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Builds the hot-path scorer. Everything is hoisted into locals so the returned closure is
|
|
108
|
+
* monomorphic: integer-only, no allocation, no division, no property lookups per candidate.
|
|
109
|
+
*/
|
|
110
|
+
export function makeScorer(face: CompiledFace): (data: Uint8Array) => number {
|
|
111
|
+
const {
|
|
112
|
+
nFixed,
|
|
113
|
+
fixedIndex,
|
|
114
|
+
fixedTarget,
|
|
115
|
+
fixedWeight,
|
|
116
|
+
nRegions,
|
|
117
|
+
regionIndexStart,
|
|
118
|
+
regionLength,
|
|
119
|
+
regionAltCount,
|
|
120
|
+
regionCellStart,
|
|
121
|
+
regionIndex,
|
|
122
|
+
regionCells,
|
|
123
|
+
regionWeights,
|
|
124
|
+
} = face
|
|
125
|
+
|
|
126
|
+
return function score(data: Uint8Array): number {
|
|
127
|
+
let total = 0
|
|
128
|
+
for (let i = 0; i < nFixed; i++) {
|
|
129
|
+
if (data[fixedIndex[i]] === fixedTarget[i]) total += fixedWeight[i]
|
|
130
|
+
}
|
|
131
|
+
for (let r = 0; r < nRegions; r++) {
|
|
132
|
+
const indexBase = regionIndexStart[r]
|
|
133
|
+
const length = regionLength[r]
|
|
134
|
+
const altCount = regionAltCount[r]
|
|
135
|
+
const cellBase = regionCellStart[r]
|
|
136
|
+
let best = 0
|
|
137
|
+
for (let a = 0; a < altCount; a++) {
|
|
138
|
+
const offset = cellBase + a * length
|
|
139
|
+
let got = 0
|
|
140
|
+
for (let j = 0; j < length; j++) {
|
|
141
|
+
if (data[regionIndex[indexBase + j]] === regionCells[offset + j]) {
|
|
142
|
+
got += regionWeights[offset + j]
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (got > best) best = got
|
|
146
|
+
}
|
|
147
|
+
total += best
|
|
148
|
+
}
|
|
149
|
+
return total
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Off the hot path: which alternative won in each region, plus the total score. */
|
|
154
|
+
export function describeMatch(
|
|
155
|
+
face: CompiledFace,
|
|
156
|
+
data: Uint8Array,
|
|
157
|
+
): { score: number; regions: Record<string, string> } {
|
|
158
|
+
let total = 0
|
|
159
|
+
for (let i = 0; i < face.nFixed; i++) {
|
|
160
|
+
if (data[face.fixedIndex[i]] === face.fixedTarget[i]) total += face.fixedWeight[i]
|
|
161
|
+
}
|
|
162
|
+
const regions: Record<string, string> = {}
|
|
163
|
+
for (let r = 0; r < face.nRegions; r++) {
|
|
164
|
+
const indexBase = face.regionIndexStart[r]
|
|
165
|
+
const length = face.regionLength[r]
|
|
166
|
+
const cellBase = face.regionCellStart[r]
|
|
167
|
+
let best = -1
|
|
168
|
+
let bestAlt = 0
|
|
169
|
+
for (let a = 0; a < face.regionAltCount[r]; a++) {
|
|
170
|
+
const offset = cellBase + a * length
|
|
171
|
+
let got = 0
|
|
172
|
+
for (let j = 0; j < length; j++) {
|
|
173
|
+
if (data[face.regionIndex[indexBase + j]] === face.regionCells[offset + j]) {
|
|
174
|
+
got += face.regionWeights[offset + j]
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (got > best) {
|
|
178
|
+
best = got
|
|
179
|
+
bestAlt = a
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
total += best
|
|
183
|
+
regions[face.regionNames[r]] = face.regionAltNames[r][bestAlt]
|
|
184
|
+
}
|
|
185
|
+
return { score: total, regions }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** True when the grid never uses blo's spot colour, i.e. the blockie renders in two colours. */
|
|
189
|
+
export function isTwoColor(data: Uint8Array): boolean {
|
|
190
|
+
for (let i = 0; i < data.length; i++) if (data[i] === 2) return false
|
|
191
|
+
return true
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** blo emits h 0-360, s 0-100, l 0-100. Returns r/g/b in 0-255. */
|
|
195
|
+
export function hslToRgb(h: number, s: number, l: number): [number, number, number] {
|
|
196
|
+
const saturation = s / 100
|
|
197
|
+
const lightness = l / 100
|
|
198
|
+
const k = (n: number) => (n + h / 30) % 12
|
|
199
|
+
const a = saturation * Math.min(lightness, 1 - lightness)
|
|
200
|
+
const f = (n: number) => lightness - a * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1))
|
|
201
|
+
return [f(0) * 255, f(8) * 255, f(4) * 255]
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Euclidean RGB distance between two HSL colours. 0 = identical, ~441.7 = black vs white. */
|
|
205
|
+
export function colorContrast(a: Hsl, b: Hsl): number {
|
|
206
|
+
const [r1, g1, b1] = hslToRgb(a[0], a[1], a[2])
|
|
207
|
+
const [r2, g2, b2] = hslToRgb(b[0], b[1], b[2])
|
|
208
|
+
return Math.hypot(r1 - r2, g1 - g2, b1 - b2)
|
|
209
|
+
}
|