@stasho/vf-records 0.0.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.
- package/README.md +13 -0
- package/dist/base58.d.ts +3 -0
- package/dist/base58.js +50 -0
- package/dist/client-check.d.ts +31 -0
- package/dist/client-check.js +60 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +4 -0
- package/dist/legacy-message.d.ts +19 -0
- package/dist/legacy-message.js +65 -0
- package/dist/records.d.ts +56 -0
- package/dist/records.js +122 -0
- package/package.json +33 -0
package/README.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# @stasho/vf-records
|
|
2
|
+
|
|
3
|
+
The wire codec for Stasho Verified Frontends records: domain/authority
|
|
4
|
+
normalization, the on-chain memo payload encoder, and a raw Solana legacy
|
|
5
|
+
message decoder.
|
|
6
|
+
|
|
7
|
+
`checkPreparedMessage` is the public verifier-side helper — it re-derives
|
|
8
|
+
what a prepared publish or transfer message should say and compares it
|
|
9
|
+
against what the signer actually asked for, before that signer's key ever
|
|
10
|
+
touches the message.
|
|
11
|
+
|
|
12
|
+
Zero runtime dependencies, no Node-only imports — safe to import from a
|
|
13
|
+
browser or from `@stasho/vf`.
|
package/dist/base58.d.ts
ADDED
package/dist/base58.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
const ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
|
2
|
+
const INDEX = new Map([...ALPHABET].map((c, i) => [c, i]));
|
|
3
|
+
/** Bitcoin-alphabet base58, the Solana address encoding. Zero dependencies. */
|
|
4
|
+
export function base58Encode(bytes) {
|
|
5
|
+
let zeros = 0;
|
|
6
|
+
while (zeros < bytes.length && bytes[zeros] === 0)
|
|
7
|
+
zeros++;
|
|
8
|
+
const digits = [];
|
|
9
|
+
for (let i = zeros; i < bytes.length; i++) {
|
|
10
|
+
let carry = bytes[i] ?? 0;
|
|
11
|
+
for (let j = 0; j < digits.length; j++) {
|
|
12
|
+
carry += (digits[j] ?? 0) << 8;
|
|
13
|
+
digits[j] = carry % 58;
|
|
14
|
+
carry = Math.floor(carry / 58);
|
|
15
|
+
}
|
|
16
|
+
while (carry > 0) {
|
|
17
|
+
digits.push(carry % 58);
|
|
18
|
+
carry = Math.floor(carry / 58);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
let out = "1".repeat(zeros);
|
|
22
|
+
for (let i = digits.length - 1; i >= 0; i--)
|
|
23
|
+
out += ALPHABET[digits[i] ?? 0];
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
export function base58Decode(text) {
|
|
27
|
+
let zeros = 0;
|
|
28
|
+
while (zeros < text.length && text[zeros] === "1")
|
|
29
|
+
zeros++;
|
|
30
|
+
const bytes = [];
|
|
31
|
+
for (let i = zeros; i < text.length; i++) {
|
|
32
|
+
const value = INDEX.get(text[i] ?? "");
|
|
33
|
+
if (value === undefined)
|
|
34
|
+
throw new RangeError(`invalid base58 character ${JSON.stringify(text[i])}`);
|
|
35
|
+
let carry = value;
|
|
36
|
+
for (let j = 0; j < bytes.length; j++) {
|
|
37
|
+
carry += (bytes[j] ?? 0) * 58;
|
|
38
|
+
bytes[j] = carry & 0xff;
|
|
39
|
+
carry >>= 8;
|
|
40
|
+
}
|
|
41
|
+
while (carry > 0) {
|
|
42
|
+
bytes.push(carry & 0xff);
|
|
43
|
+
carry >>= 8;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const out = new Uint8Array(zeros + bytes.length);
|
|
47
|
+
for (let i = 0; i < bytes.length; i++)
|
|
48
|
+
out[zeros + i] = bytes[bytes.length - 1 - i] ?? 0;
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type VfRecord } from "./records.ts";
|
|
2
|
+
export type ExpectedRecord = {
|
|
3
|
+
kind: "pub";
|
|
4
|
+
domain: string;
|
|
5
|
+
cid: string;
|
|
6
|
+
version: string;
|
|
7
|
+
} | {
|
|
8
|
+
kind: "transfer";
|
|
9
|
+
domain: string;
|
|
10
|
+
newVault: string;
|
|
11
|
+
};
|
|
12
|
+
export type CheckResult = {
|
|
13
|
+
ok: true;
|
|
14
|
+
payload: string;
|
|
15
|
+
record: VfRecord;
|
|
16
|
+
} | {
|
|
17
|
+
ok: false;
|
|
18
|
+
reason: string;
|
|
19
|
+
};
|
|
20
|
+
export declare const CLIENT_CHECK_SKEW_MS: number;
|
|
21
|
+
/**
|
|
22
|
+
* Spec §4 step 2. Everything a compromised backend could vary is pinned to
|
|
23
|
+
* what the client obtained on its own: the signer, the program, the account
|
|
24
|
+
* list, and every payload field except the server timestamp (bounded).
|
|
25
|
+
*/
|
|
26
|
+
export declare function checkPreparedMessage(args: {
|
|
27
|
+
message: Uint8Array;
|
|
28
|
+
signer: Uint8Array;
|
|
29
|
+
expected: ExpectedRecord;
|
|
30
|
+
now: number;
|
|
31
|
+
}): CheckResult;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { decodeLegacyMessage } from "./legacy-message.js";
|
|
2
|
+
import { MEMO_PROGRAM_ID, encodeRecord, parsePayload } from "./records.js";
|
|
3
|
+
import { base58Decode } from "./base58.js";
|
|
4
|
+
export const CLIENT_CHECK_SKEW_MS = 10 * 60_000;
|
|
5
|
+
const MEMO_KEY = base58Decode(MEMO_PROGRAM_ID);
|
|
6
|
+
const eq = (a, b) => a.length === b.length && a.every((v, i) => v === b[i]);
|
|
7
|
+
const refuse = (reason) => ({ ok: false, reason });
|
|
8
|
+
/**
|
|
9
|
+
* Spec §4 step 2. Everything a compromised backend could vary is pinned to
|
|
10
|
+
* what the client obtained on its own: the signer, the program, the account
|
|
11
|
+
* list, and every payload field except the server timestamp (bounded).
|
|
12
|
+
*/
|
|
13
|
+
export function checkPreparedMessage(args) {
|
|
14
|
+
let m;
|
|
15
|
+
try {
|
|
16
|
+
m = decodeLegacyMessage(args.message);
|
|
17
|
+
}
|
|
18
|
+
catch (err) {
|
|
19
|
+
return refuse(`could not decode the message: ${err.message}`);
|
|
20
|
+
}
|
|
21
|
+
if (m.numRequiredSignatures !== 2)
|
|
22
|
+
return refuse(`header requires ${m.numRequiredSignatures} signatures, expected two signatures (fee payer + you)`);
|
|
23
|
+
const feePayer = m.accountKeys[0];
|
|
24
|
+
const customer = m.accountKeys[1];
|
|
25
|
+
if (!feePayer || !customer || !eq(customer, args.signer))
|
|
26
|
+
return refuse("account 1 is not your key");
|
|
27
|
+
if (eq(feePayer, args.signer))
|
|
28
|
+
return refuse("your key is listed as the fee payer");
|
|
29
|
+
if (m.instructions.length !== 1)
|
|
30
|
+
return refuse(`message has ${m.instructions.length} instructions, expected exactly one instruction`);
|
|
31
|
+
const ix = m.instructions[0];
|
|
32
|
+
const program = m.accountKeys[ix.programIdIndex];
|
|
33
|
+
if (!program || !eq(program, MEMO_KEY))
|
|
34
|
+
return refuse("instruction program is not the memo program");
|
|
35
|
+
if (ix.accounts.length !== 1 || ix.accounts[0] !== 1)
|
|
36
|
+
return refuse("your key must be the instruction's only account");
|
|
37
|
+
const payload = new TextDecoder().decode(ix.data);
|
|
38
|
+
const record = parsePayload(payload);
|
|
39
|
+
if (record === null)
|
|
40
|
+
return refuse("instruction data is not a stasho-vf record");
|
|
41
|
+
if (encodeRecord(record) !== payload)
|
|
42
|
+
return refuse("record payload carries content outside the canonical encoding");
|
|
43
|
+
if (record.kind !== args.expected.kind)
|
|
44
|
+
return refuse(`record is a ${record.kind}, you asked for a ${args.expected.kind}`);
|
|
45
|
+
if (record.domain !== args.expected.domain)
|
|
46
|
+
return refuse(`record is for ${record.domain}, not ${args.expected.domain}`);
|
|
47
|
+
if (record.kind === "pub" && args.expected.kind === "pub") {
|
|
48
|
+
if (record.cid !== args.expected.cid)
|
|
49
|
+
return refuse(`record cid ${record.cid} differs from ${args.expected.cid}`);
|
|
50
|
+
if (record.version !== args.expected.version)
|
|
51
|
+
return refuse(`record version ${record.version} differs from ${args.expected.version}`);
|
|
52
|
+
}
|
|
53
|
+
if (record.kind === "transfer" && args.expected.kind === "transfer" && record.newVault !== args.expected.newVault) {
|
|
54
|
+
return refuse(`transfer target ${record.newVault} differs from ${args.expected.newVault}`);
|
|
55
|
+
}
|
|
56
|
+
const t = Date.parse(record.ts);
|
|
57
|
+
if (!Number.isFinite(t) || Math.abs(t - args.now) > CLIENT_CHECK_SKEW_MS)
|
|
58
|
+
return refuse("record timestamp is more than 10 minutes from this clock");
|
|
59
|
+
return { ok: true, payload, record };
|
|
60
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export * from "./records.ts";
|
|
2
|
+
export { base58Decode, base58Encode } from "./base58.ts";
|
|
3
|
+
export type { LegacyInstruction, LegacyMessage } from "./legacy-message.ts";
|
|
4
|
+
export { decodeLegacyMessage } from "./legacy-message.ts";
|
|
5
|
+
export type { CheckResult, ExpectedRecord } from "./client-check.ts";
|
|
6
|
+
export { checkPreparedMessage, CLIENT_CHECK_SKEW_MS } from "./client-check.ts";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface LegacyInstruction {
|
|
2
|
+
programIdIndex: number;
|
|
3
|
+
accounts: number[];
|
|
4
|
+
data: Uint8Array;
|
|
5
|
+
}
|
|
6
|
+
export interface LegacyMessage {
|
|
7
|
+
numRequiredSignatures: number;
|
|
8
|
+
numReadonlySignedAccounts: number;
|
|
9
|
+
numReadonlyUnsignedAccounts: number;
|
|
10
|
+
accountKeys: Uint8Array[];
|
|
11
|
+
recentBlockhash: Uint8Array;
|
|
12
|
+
instructions: LegacyInstruction[];
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Decodes a LEGACY Solana transaction message (the shape `prepare` builds).
|
|
16
|
+
* Versioned messages (first byte with the high bit set) are refused: the
|
|
17
|
+
* publish path never builds one, so one arriving is not ours to sign.
|
|
18
|
+
*/
|
|
19
|
+
export declare function decodeLegacyMessage(bytes: Uint8Array): LegacyMessage;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
class Reader {
|
|
2
|
+
bytes;
|
|
3
|
+
offset = 0;
|
|
4
|
+
constructor(bytes) {
|
|
5
|
+
this.bytes = bytes;
|
|
6
|
+
}
|
|
7
|
+
u8() {
|
|
8
|
+
const v = this.bytes[this.offset];
|
|
9
|
+
if (v === undefined)
|
|
10
|
+
throw new RangeError(`message truncated at byte ${this.offset}`);
|
|
11
|
+
this.offset += 1;
|
|
12
|
+
return v;
|
|
13
|
+
}
|
|
14
|
+
/** Solana "compact-u16" (ShortVec): little-endian 7-bit groups, high bit = more. */
|
|
15
|
+
compactU16() {
|
|
16
|
+
let value = 0;
|
|
17
|
+
for (let shift = 0; shift <= 14; shift += 7) {
|
|
18
|
+
const b = this.u8();
|
|
19
|
+
value |= (b & 0x7f) << shift;
|
|
20
|
+
if ((b & 0x80) === 0)
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
throw new RangeError("compact-u16 longer than 3 bytes");
|
|
24
|
+
}
|
|
25
|
+
take(n) {
|
|
26
|
+
if (this.offset + n > this.bytes.length)
|
|
27
|
+
throw new RangeError(`message truncated at byte ${this.offset}`);
|
|
28
|
+
const out = this.bytes.slice(this.offset, this.offset + n);
|
|
29
|
+
this.offset += n;
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
done() { return this.offset === this.bytes.length; }
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Decodes a LEGACY Solana transaction message (the shape `prepare` builds).
|
|
36
|
+
* Versioned messages (first byte with the high bit set) are refused: the
|
|
37
|
+
* publish path never builds one, so one arriving is not ours to sign.
|
|
38
|
+
*/
|
|
39
|
+
export function decodeLegacyMessage(bytes) {
|
|
40
|
+
if ((bytes[0] ?? 0) & 0x80)
|
|
41
|
+
throw new RangeError("versioned message, expected legacy");
|
|
42
|
+
const r = new Reader(bytes);
|
|
43
|
+
const numRequiredSignatures = r.u8();
|
|
44
|
+
const numReadonlySignedAccounts = r.u8();
|
|
45
|
+
const numReadonlyUnsignedAccounts = r.u8();
|
|
46
|
+
const keyCount = r.compactU16();
|
|
47
|
+
const accountKeys = [];
|
|
48
|
+
for (let i = 0; i < keyCount; i++)
|
|
49
|
+
accountKeys.push(r.take(32));
|
|
50
|
+
const recentBlockhash = r.take(32);
|
|
51
|
+
const ixCount = r.compactU16();
|
|
52
|
+
const instructions = [];
|
|
53
|
+
for (let i = 0; i < ixCount; i++) {
|
|
54
|
+
const programIdIndex = r.u8();
|
|
55
|
+
const accountCount = r.compactU16();
|
|
56
|
+
const accounts = [];
|
|
57
|
+
for (let j = 0; j < accountCount; j++)
|
|
58
|
+
accounts.push(r.u8());
|
|
59
|
+
const data = r.take(r.compactU16());
|
|
60
|
+
instructions.push({ programIdIndex, accounts, data });
|
|
61
|
+
}
|
|
62
|
+
if (!r.done())
|
|
63
|
+
throw new RangeError("trailing bytes after the last instruction");
|
|
64
|
+
return { numRequiredSignatures, numReadonlySignedAccounts, numReadonlyUnsignedAccounts, accountKeys, recentBlockhash, instructions };
|
|
65
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Solana record codec (spec §5.3).
|
|
3
|
+
*
|
|
4
|
+
* Authority comes from ONE place: the SPL Memo program's own `Signed by <addr>`
|
|
5
|
+
* log line inside a SUCCESSFUL memo invoke bracket. The memo program verifies
|
|
6
|
+
* that every account passed to it is a signer of the executing instruction, so
|
|
7
|
+
* this holds for a PDA signing via CPI (Squads) exactly as for a plain keypair,
|
|
8
|
+
* without trusting any multisig program's internals.
|
|
9
|
+
*
|
|
10
|
+
* NEVER infer authority from account presence: `getSignaturesForAddress`
|
|
11
|
+
* returns every transaction that MENTIONS an address, and an attacker can land
|
|
12
|
+
* a transaction that mentions a vault while carrying a top-level memo with a
|
|
13
|
+
* forged payload. That transaction has no `Signed by <vault>` line, which is
|
|
14
|
+
* the only thing that separates it from a genuine record.
|
|
15
|
+
*/
|
|
16
|
+
export declare const MEMO_PROGRAM_ID = "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr";
|
|
17
|
+
export declare const VF_PREFIX = "stasho-vf:1:";
|
|
18
|
+
export declare const SOLANA_BASE58_RE: RegExp;
|
|
19
|
+
/**
|
|
20
|
+
* WHATWG-URL punycode: identical bytes in Node and browsers (`node:url` is
|
|
21
|
+
* off-limits in this package). The URL parser REPAIRS some input (percent
|
|
22
|
+
* decoding, trailing-dot stripping); every character it would repair is
|
|
23
|
+
* rejected up front so repaired input is never accepted (spec §2).
|
|
24
|
+
*/
|
|
25
|
+
export declare function normalizeDomain(input: string): string | null;
|
|
26
|
+
/**
|
|
27
|
+
* Parses a chain-namespaced authority string (`solana:<base58>`). Every
|
|
28
|
+
* authority address on the wire and in the bindings store carries this
|
|
29
|
+
* namespace so a bare address is never mistaken for one (spec, Decision #417
|
|
30
|
+
* item 8).
|
|
31
|
+
*/
|
|
32
|
+
export declare function parseAuthority(value: string): {
|
|
33
|
+
chain: "solana";
|
|
34
|
+
address: string;
|
|
35
|
+
} | null;
|
|
36
|
+
export declare function formatAuthority(address: string): string;
|
|
37
|
+
export type VfRecord = {
|
|
38
|
+
kind: "pub";
|
|
39
|
+
domain: string;
|
|
40
|
+
cid: string;
|
|
41
|
+
version: string;
|
|
42
|
+
ts: string;
|
|
43
|
+
} | {
|
|
44
|
+
kind: "transfer";
|
|
45
|
+
domain: string;
|
|
46
|
+
newVault: string;
|
|
47
|
+
ts: string;
|
|
48
|
+
} | {
|
|
49
|
+
kind: "bind";
|
|
50
|
+
domain: string;
|
|
51
|
+
vault: string;
|
|
52
|
+
op: "bind" | "unbind";
|
|
53
|
+
ts: string;
|
|
54
|
+
};
|
|
55
|
+
export declare function parsePayload(payload: string): VfRecord | null;
|
|
56
|
+
export declare function encodeRecord(record: VfRecord): string;
|
package/dist/records.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Solana record codec (spec §5.3).
|
|
3
|
+
*
|
|
4
|
+
* Authority comes from ONE place: the SPL Memo program's own `Signed by <addr>`
|
|
5
|
+
* log line inside a SUCCESSFUL memo invoke bracket. The memo program verifies
|
|
6
|
+
* that every account passed to it is a signer of the executing instruction, so
|
|
7
|
+
* this holds for a PDA signing via CPI (Squads) exactly as for a plain keypair,
|
|
8
|
+
* without trusting any multisig program's internals.
|
|
9
|
+
*
|
|
10
|
+
* NEVER infer authority from account presence: `getSignaturesForAddress`
|
|
11
|
+
* returns every transaction that MENTIONS an address, and an attacker can land
|
|
12
|
+
* a transaction that mentions a vault while carrying a top-level memo with a
|
|
13
|
+
* forged payload. That transaction has no `Signed by <vault>` line, which is
|
|
14
|
+
* the only thing that separates it from a genuine record.
|
|
15
|
+
*/
|
|
16
|
+
export const MEMO_PROGRAM_ID = "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr";
|
|
17
|
+
export const VF_PREFIX = "stasho-vf:1:";
|
|
18
|
+
export const SOLANA_BASE58_RE = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/;
|
|
19
|
+
const LABEL_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
20
|
+
/**
|
|
21
|
+
* WHATWG-URL punycode: identical bytes in Node and browsers (`node:url` is
|
|
22
|
+
* off-limits in this package). The URL parser REPAIRS some input (percent
|
|
23
|
+
* decoding, trailing-dot stripping); every character it would repair is
|
|
24
|
+
* rejected up front so repaired input is never accepted (spec §2).
|
|
25
|
+
*/
|
|
26
|
+
export function normalizeDomain(input) {
|
|
27
|
+
const trimmed = input.trim().toLowerCase().replace(/\.$/, "");
|
|
28
|
+
if (trimmed.length === 0 || trimmed.length > 253)
|
|
29
|
+
return null;
|
|
30
|
+
if (/[/:?#@\s[\]%\\]/.test(trimmed))
|
|
31
|
+
return null;
|
|
32
|
+
let ascii;
|
|
33
|
+
try {
|
|
34
|
+
ascii = new URL(`http://${trimmed}/`).hostname;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
const labels = ascii.split(".");
|
|
40
|
+
if (labels.length < 2)
|
|
41
|
+
return null;
|
|
42
|
+
if (!labels.every((l) => LABEL_RE.test(l)))
|
|
43
|
+
return null;
|
|
44
|
+
return ascii;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Parses a chain-namespaced authority string (`solana:<base58>`). Every
|
|
48
|
+
* authority address on the wire and in the bindings store carries this
|
|
49
|
+
* namespace so a bare address is never mistaken for one (spec, Decision #417
|
|
50
|
+
* item 8).
|
|
51
|
+
*/
|
|
52
|
+
export function parseAuthority(value) {
|
|
53
|
+
const sep = value.indexOf(":");
|
|
54
|
+
if (sep < 0)
|
|
55
|
+
return null;
|
|
56
|
+
const chain = value.slice(0, sep);
|
|
57
|
+
const address = value.slice(sep + 1);
|
|
58
|
+
if (chain !== "solana" || !SOLANA_BASE58_RE.test(address))
|
|
59
|
+
return null;
|
|
60
|
+
return { chain: "solana", address };
|
|
61
|
+
}
|
|
62
|
+
export function formatAuthority(address) {
|
|
63
|
+
return `solana:${address}`;
|
|
64
|
+
}
|
|
65
|
+
export function parsePayload(payload) {
|
|
66
|
+
if (!payload.startsWith(VF_PREFIX))
|
|
67
|
+
return null;
|
|
68
|
+
const rest = payload.slice(VF_PREFIX.length);
|
|
69
|
+
const separator = rest.indexOf(":");
|
|
70
|
+
if (separator < 0)
|
|
71
|
+
return null;
|
|
72
|
+
const kind = rest.slice(0, separator);
|
|
73
|
+
let body;
|
|
74
|
+
try {
|
|
75
|
+
const parsed = JSON.parse(rest.slice(separator + 1));
|
|
76
|
+
if (typeof parsed !== "object" || parsed === null)
|
|
77
|
+
return null;
|
|
78
|
+
body = parsed;
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
const str = (key) => typeof body[key] === "string" && body[key] !== "" ? body[key] : null;
|
|
84
|
+
const domain = str("d");
|
|
85
|
+
const ts = str("t");
|
|
86
|
+
if (!domain || !ts)
|
|
87
|
+
return null;
|
|
88
|
+
if (normalizeDomain(domain) !== domain)
|
|
89
|
+
return null;
|
|
90
|
+
if (kind === "pub") {
|
|
91
|
+
const cid = str("c");
|
|
92
|
+
const version = str("v");
|
|
93
|
+
return cid && version ? { kind: "pub", domain, cid, version, ts } : null;
|
|
94
|
+
}
|
|
95
|
+
if (kind === "transfer") {
|
|
96
|
+
const newVault = str("newVault");
|
|
97
|
+
return newVault && parseAuthority(newVault) ? { kind: "transfer", domain, newVault, ts } : null;
|
|
98
|
+
}
|
|
99
|
+
if (kind === "bind") {
|
|
100
|
+
const vault = str("vault");
|
|
101
|
+
const op = str("op");
|
|
102
|
+
if (!vault || !parseAuthority(vault) || (op !== "bind" && op !== "unbind"))
|
|
103
|
+
return null;
|
|
104
|
+
return { kind: "bind", domain, vault, op, ts };
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
export function encodeRecord(record) {
|
|
109
|
+
if (record.kind === "pub") {
|
|
110
|
+
return `${VF_PREFIX}pub:${JSON.stringify({
|
|
111
|
+
d: record.domain, c: record.cid, v: record.version, t: record.ts,
|
|
112
|
+
})}`;
|
|
113
|
+
}
|
|
114
|
+
if (record.kind === "transfer") {
|
|
115
|
+
return `${VF_PREFIX}transfer:${JSON.stringify({
|
|
116
|
+
d: record.domain, newVault: record.newVault, t: record.ts,
|
|
117
|
+
})}`;
|
|
118
|
+
}
|
|
119
|
+
return `${VF_PREFIX}bind:${JSON.stringify({
|
|
120
|
+
d: record.domain, vault: record.vault, op: record.op, t: record.ts,
|
|
121
|
+
})}`;
|
|
122
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@stasho/vf-records",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Stasho Verified Frontends record codec + the client-side check of a prepared publish message. Zero dependencies, browser-safe.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"files": [
|
|
9
|
+
"dist",
|
|
10
|
+
"README.md"
|
|
11
|
+
],
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"import": "./dist/index.js"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsc -p tsconfig.build.json",
|
|
20
|
+
"typecheck": "tsc --noEmit",
|
|
21
|
+
"test": "vitest run",
|
|
22
|
+
"lint": "oxlint src tests",
|
|
23
|
+
"prepublishOnly": "npm run build"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@solana/web3.js": "1.98.4",
|
|
27
|
+
"@types/node": "22.14.0",
|
|
28
|
+
"bs58": "6.0.0",
|
|
29
|
+
"oxlint": "1.58.0",
|
|
30
|
+
"typescript": "5.9.3",
|
|
31
|
+
"vitest": "4.1.0"
|
|
32
|
+
}
|
|
33
|
+
}
|