@effectstream/sm 0.100.11 → 0.100.13
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/package.json +8 -8
- package/primitives/src/builtin.ts +12 -0
- package/primitives/src/cardano-delayed-asset/delayed-asset-grammar.ts +11 -0
- package/primitives/src/cardano-delayed-asset/delayed-asset-ivm.ts +89 -0
- package/primitives/src/cardano-delayed-asset/delayed-asset-primitive.ts +228 -0
- package/primitives/src/cardano-mint-burn/mint-burn-grammar.ts +9 -0
- package/primitives/src/cardano-mint-burn/mint-burn-primitive.ts +160 -0
- package/primitives/src/cardano-pool-delegation/pool-delegation-grammar.ts +7 -0
- package/primitives/src/cardano-pool-delegation/pool-delegation-ivm.ts +65 -0
- package/primitives/src/cardano-pool-delegation/pool-delegation-primitive.ts +150 -0
- package/primitives/src/cardano-projected-nft/projected-nft-datum.ts +107 -0
- package/primitives/src/cardano-projected-nft/projected-nft-grammar.ts +13 -0
- package/primitives/src/cardano-projected-nft/projected-nft-ivm.ts +108 -0
- package/primitives/src/cardano-projected-nft/projected-nft-primitive.ts +242 -0
- package/primitives/src/cardano-transfer/transfer-grammar.ts +8 -0
- package/primitives/src/cardano-transfer/transfer-primitive.ts +150 -0
- package/primitives/src/cardano-utils/cardano-helpers.ts +84 -0
- package/primitives/src/grammar.ts +10 -0
- package/primitives/src/midnight-generic/midnight-genetic.ts +15 -0
- package/primitives/src/midnight-unshielded-spend/midnight-unshielded-spend-grammar.ts +8 -0
- package/primitives/src/midnight-unshielded-spend/midnight-unshielded-spend.ts +115 -0
- package/primitives/src/mod.ts +24 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cardano Pool Delegation Primitive
|
|
3
|
+
*
|
|
4
|
+
* Tracks stake pool delegation events from Dolos UTxORPC block data.
|
|
5
|
+
*
|
|
6
|
+
* Data source: Dolos streams raw Cardano blocks via gRPC. Each transaction contains
|
|
7
|
+
* a `certificates` array — delegation certificates include `stakeDelegation` (pre-Conway)
|
|
8
|
+
* and `stakeRegDelegCert`/`stakeVoteDelegCert` (Conway era). Each certificate carries
|
|
9
|
+
* a `stakeCredential` (key or script hash) and a `poolKeyhash` (target pool).
|
|
10
|
+
*
|
|
11
|
+
* Extraction: `getPayload()` iterates `tx.certificates`, filters for delegation-type
|
|
12
|
+
* certificates, extracts the staking credential hash and pool keyhash. The epoch is
|
|
13
|
+
* computed from `absoluteSlot` (passed from the fetcher via block headers) using
|
|
14
|
+
* network-specific era parameters (e.g., YACI: 600 slots/epoch, mainnet: 432000).
|
|
15
|
+
* Optionally filters by configured pool hashes.
|
|
16
|
+
*
|
|
17
|
+
* Predicate: uses `has_certificate: true` so Dolos only sends transactions containing
|
|
18
|
+
* certificates, filtering out the vast majority of transfer-only transactions.
|
|
19
|
+
*
|
|
20
|
+
* IVM: maintains a materialized view of current delegations per staking credential,
|
|
21
|
+
* using PostgreSQL triggers to UPSERT on each new delegation event.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { StaticDecode } from "@sinclair/typebox";
|
|
25
|
+
import {
|
|
26
|
+
type CommandTuple,
|
|
27
|
+
generateRawStmInput,
|
|
28
|
+
} from "@effectstream/concise";
|
|
29
|
+
import type {
|
|
30
|
+
ConfigSyncProtocolType,
|
|
31
|
+
FlattenSyncProtocolIOFor,
|
|
32
|
+
ProtocolPrimitiveMap,
|
|
33
|
+
} from "@effectstream/config";
|
|
34
|
+
import type { StateUpdateStream } from "@effectstream/coroutine";
|
|
35
|
+
import { type JsonObject, Primitive } from "@effectstream/sm";
|
|
36
|
+
import {
|
|
37
|
+
type AddressAndType,
|
|
38
|
+
AddressType,
|
|
39
|
+
type EffectstreamBlockNumber,
|
|
40
|
+
uint8ArrayToHexString,
|
|
41
|
+
} from "@effectstream/utils";
|
|
42
|
+
import { poolDelegationGrammar } from "./pool-delegation-grammar.ts";
|
|
43
|
+
import {
|
|
44
|
+
POOL_DELEGATION_INTERMEDIATE_PREFIX,
|
|
45
|
+
POOL_DELEGATION_VIEW_PREFIX,
|
|
46
|
+
poolDelegationIvm,
|
|
47
|
+
} from "./pool-delegation-ivm.ts";
|
|
48
|
+
import { PrimitiveTypeCardanoPoolDelegation } from "../builtin.ts";
|
|
49
|
+
import { slotToEpoch, stakeCredentialToHex } from "../cardano-utils/cardano-helpers.ts";
|
|
50
|
+
|
|
51
|
+
type CardanoNetwork = "preview" | "preprod" | "mainnet" | "yaci";
|
|
52
|
+
|
|
53
|
+
export class CardanoPoolDelegationPrimitive extends Primitive<
|
|
54
|
+
ConfigSyncProtocolType.CARDANO_UTXORPC_PARALLEL,
|
|
55
|
+
typeof poolDelegationGrammar
|
|
56
|
+
> {
|
|
57
|
+
readonly internalTypeName = PrimitiveTypeCardanoPoolDelegation;
|
|
58
|
+
readonly pools: string[];
|
|
59
|
+
readonly network: CardanoNetwork;
|
|
60
|
+
override grammar = poolDelegationGrammar;
|
|
61
|
+
|
|
62
|
+
override dynamicTables = poolDelegationIvm;
|
|
63
|
+
override getIntermediatePrefix(): string[] {
|
|
64
|
+
return [POOL_DELEGATION_INTERMEDIATE_PREFIX];
|
|
65
|
+
}
|
|
66
|
+
override getViewPrefix(): string[] {
|
|
67
|
+
return [POOL_DELEGATION_VIEW_PREFIX];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
constructor(config: {
|
|
71
|
+
instanceName: string;
|
|
72
|
+
startBlockHeight: number;
|
|
73
|
+
stateMachinePrefix: string | undefined;
|
|
74
|
+
pools: string[];
|
|
75
|
+
network: CardanoNetwork;
|
|
76
|
+
}) {
|
|
77
|
+
super(config);
|
|
78
|
+
this.pools = config.pools.map((p) => p.toLowerCase());
|
|
79
|
+
this.network = config.network;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
override getConfig(): ProtocolPrimitiveMap[ConfigSyncProtocolType.CARDANO_UTXORPC_PARALLEL] {
|
|
83
|
+
return {
|
|
84
|
+
name: this.instanceName,
|
|
85
|
+
type: this.internalTypeName,
|
|
86
|
+
startBlockHeight: this.startBlockHeight,
|
|
87
|
+
scheduledPrefix: this.stateMachinePrefix,
|
|
88
|
+
predicate: { match: { cardano: { has_certificate: true } } },
|
|
89
|
+
} as const;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
override *getPayload(
|
|
93
|
+
_: EffectstreamBlockNumber,
|
|
94
|
+
primitiveTransactionData: FlattenSyncProtocolIOFor<ConfigSyncProtocolType.CARDANO_UTXORPC_PARALLEL>,
|
|
95
|
+
): StateUpdateStream<{
|
|
96
|
+
isBatched: boolean;
|
|
97
|
+
data: {
|
|
98
|
+
fromAddressAndType: AddressAndType;
|
|
99
|
+
stateMachinePayload: StaticDecode<CommandTuple<string, typeof poolDelegationGrammar>> | null;
|
|
100
|
+
accountingPayload: JsonObject;
|
|
101
|
+
}[];
|
|
102
|
+
}> {
|
|
103
|
+
const tx = primitiveTransactionData.output.payload.tx;
|
|
104
|
+
const absoluteSlot = primitiveTransactionData.syncProtocol.absoluteSlot ?? 0;
|
|
105
|
+
const epoch = slotToEpoch(absoluteSlot, this.network);
|
|
106
|
+
|
|
107
|
+
const entries: {
|
|
108
|
+
fromAddressAndType: AddressAndType;
|
|
109
|
+
stateMachinePayload: any;
|
|
110
|
+
accountingPayload: JsonObject;
|
|
111
|
+
}[] = [];
|
|
112
|
+
|
|
113
|
+
for (const cert of tx.certificates) {
|
|
114
|
+
const c = cert.certificate;
|
|
115
|
+
// Pre-Conway: stakeDelegation; Conway: stakeRegDelegCert, stakeVoteDelegCert
|
|
116
|
+
if (c.case !== "stakeDelegation" && c.case !== "stakeRegDelegCert" && c.case !== "stakeVoteDelegCert") continue;
|
|
117
|
+
|
|
118
|
+
const deleg = c.value;
|
|
119
|
+
if (!deleg.stakeCredential) continue;
|
|
120
|
+
|
|
121
|
+
const cred = stakeCredentialToHex(deleg.stakeCredential);
|
|
122
|
+
if (!cred) continue;
|
|
123
|
+
|
|
124
|
+
const poolKeyhash = uint8ArrayToHexString(deleg.poolKeyhash);
|
|
125
|
+
|
|
126
|
+
if (this.pools.length > 0 && !this.pools.includes(poolKeyhash.toLowerCase())) continue;
|
|
127
|
+
|
|
128
|
+
const address = cred.hash;
|
|
129
|
+
const pool = poolKeyhash;
|
|
130
|
+
|
|
131
|
+
const accountingPayload = { address, pool, epoch };
|
|
132
|
+
|
|
133
|
+
const stateMachinePayload: any = this.stateMachinePrefix
|
|
134
|
+
? generateRawStmInput(this.grammar, this.stateMachinePrefix, {
|
|
135
|
+
address,
|
|
136
|
+
pool,
|
|
137
|
+
epoch: String(epoch),
|
|
138
|
+
})
|
|
139
|
+
: null;
|
|
140
|
+
|
|
141
|
+
entries.push({
|
|
142
|
+
fromAddressAndType: { type: AddressType.NONE, address: "0x0" },
|
|
143
|
+
accountingPayload,
|
|
144
|
+
stateMachinePayload,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return { isBatched: true, data: entries };
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { uint8ArrayToHexString } from "@effectstream/utils";
|
|
2
|
+
import type { cardano } from "@utxorpc/spec";
|
|
3
|
+
|
|
4
|
+
export type ProjectedNftStatus = "Lock" | "Unlocking" | "Claim";
|
|
5
|
+
|
|
6
|
+
export interface ProjectedNftDatum {
|
|
7
|
+
owner: string;
|
|
8
|
+
status: ProjectedNftStatus;
|
|
9
|
+
forHowLong: string | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Parses the hololocker State datum: State { owner: Owner, status: Status }
|
|
14
|
+
* Owner: PKH(ByteArray) | NFT(PolicyId, AssetName) | Receipt(AssetName)
|
|
15
|
+
* Status: Locked | Unlocking { out_ref: OutputReference, for_how_long: Int }
|
|
16
|
+
*
|
|
17
|
+
* Note: policyId and assetName of locked assets come from the UTxO value, not the datum.
|
|
18
|
+
*/
|
|
19
|
+
export function parseProjectedNftDatum(datum: cardano.PlutusData | undefined): ProjectedNftDatum | null {
|
|
20
|
+
if (!datum) return null;
|
|
21
|
+
|
|
22
|
+
const pd = datum.plutusData;
|
|
23
|
+
if (pd.case !== "constr") return null;
|
|
24
|
+
|
|
25
|
+
const constr = pd.value;
|
|
26
|
+
if (constr.fields.length < 2) return null;
|
|
27
|
+
|
|
28
|
+
const ownerField = constr.fields[0];
|
|
29
|
+
const statusField = constr.fields[1];
|
|
30
|
+
|
|
31
|
+
const owner = parseOwner(ownerField);
|
|
32
|
+
if (owner == null) return null;
|
|
33
|
+
|
|
34
|
+
const statusParsed = parseStatus(statusField);
|
|
35
|
+
if (!statusParsed) return null;
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
owner,
|
|
39
|
+
status: statusParsed.status,
|
|
40
|
+
forHowLong: statusParsed.forHowLong,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function extractBytes(field: cardano.PlutusData): string | null {
|
|
45
|
+
const inner = field.plutusData;
|
|
46
|
+
if (inner.case === "boundedBytes") {
|
|
47
|
+
return uint8ArrayToHexString(inner.value);
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseOwner(field: cardano.PlutusData): string | null {
|
|
53
|
+
const inner = field.plutusData;
|
|
54
|
+
if (inner.case !== "constr") return null;
|
|
55
|
+
|
|
56
|
+
const tag = inner.value.tag;
|
|
57
|
+
|
|
58
|
+
// PKH(ByteArray) — constructor 0
|
|
59
|
+
if (tag === 0 || tag === 121) {
|
|
60
|
+
if (inner.value.fields.length < 1) return null;
|
|
61
|
+
return extractBytes(inner.value.fields[0]);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// NFT(PolicyId, AssetName) — constructor 1: use policyId as owner identifier
|
|
65
|
+
if (tag === 1 || tag === 122) {
|
|
66
|
+
if (inner.value.fields.length < 1) return null;
|
|
67
|
+
return extractBytes(inner.value.fields[0]);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Receipt(AssetName) — constructor 2
|
|
71
|
+
if (tag === 2 || tag === 123) {
|
|
72
|
+
if (inner.value.fields.length < 1) return null;
|
|
73
|
+
return extractBytes(inner.value.fields[0]);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function parseStatus(field: cardano.PlutusData): { status: ProjectedNftStatus; forHowLong: string | null } | null {
|
|
80
|
+
const inner = field.plutusData;
|
|
81
|
+
if (inner.case !== "constr") return null;
|
|
82
|
+
|
|
83
|
+
const tag = inner.value.tag;
|
|
84
|
+
|
|
85
|
+
// Constructor 0 = Locked
|
|
86
|
+
if (tag === 0 || tag === 121) {
|
|
87
|
+
return { status: "Lock", forHowLong: null };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Constructor 1 = Unlocking { out_ref: OutputReference, for_how_long: Int }
|
|
91
|
+
// fields[0] = OutputReference, fields[1] = for_how_long
|
|
92
|
+
if (tag === 1 || tag === 122) {
|
|
93
|
+
let forHowLong: string | null = null;
|
|
94
|
+
if (inner.value.fields.length > 1) {
|
|
95
|
+
const timefield = inner.value.fields[1].plutusData;
|
|
96
|
+
if (timefield.case === "bigInt") {
|
|
97
|
+
const bi = timefield.value.bigInt;
|
|
98
|
+
if (bi.case === "int") {
|
|
99
|
+
forHowLong = String(bi.value);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return { status: "Unlocking", forHowLong };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Type } from "@sinclair/typebox";
|
|
2
|
+
|
|
3
|
+
export const projectedNftGrammar = [
|
|
4
|
+
["ownerAddress", Type.String()],
|
|
5
|
+
["previousTxId", Type.String()],
|
|
6
|
+
["previousOutputIndex", Type.String()],
|
|
7
|
+
["currentTxId", Type.String()],
|
|
8
|
+
["currentOutputIndex", Type.String()],
|
|
9
|
+
["policyId", Type.String()],
|
|
10
|
+
["assetName", Type.String()],
|
|
11
|
+
["status", Type.String()],
|
|
12
|
+
["forHowLong", Type.String()],
|
|
13
|
+
] as const;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { PrimitiveTypeCardanoProjectedNFT } from "../builtin.ts";
|
|
2
|
+
|
|
3
|
+
export function projectedNftIvm(name: string) {
|
|
4
|
+
const lowerName = name.toLowerCase();
|
|
5
|
+
const validSQLName = lowerName.replace(/[^a-zA-Z0-9_]/g, "");
|
|
6
|
+
if (validSQLName !== lowerName) {
|
|
7
|
+
throw new Error(`Invalid name: ${name}`);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const intermediateTable = `primitives.cardano_projected_nft_intermediate_${validSQLName}`;
|
|
11
|
+
|
|
12
|
+
return `
|
|
13
|
+
CREATE TABLE IF NOT EXISTS ${intermediateTable} (
|
|
14
|
+
primitive_name TEXT NOT NULL,
|
|
15
|
+
owner_address TEXT NOT NULL,
|
|
16
|
+
current_tx_id TEXT NOT NULL,
|
|
17
|
+
current_output_index INTEGER,
|
|
18
|
+
previous_tx_id TEXT,
|
|
19
|
+
previous_output_index INTEGER,
|
|
20
|
+
policy_id TEXT NOT NULL,
|
|
21
|
+
asset_name TEXT NOT NULL,
|
|
22
|
+
status TEXT NOT NULL,
|
|
23
|
+
for_how_long TEXT,
|
|
24
|
+
block_height INTEGER NOT NULL,
|
|
25
|
+
PRIMARY KEY (primitive_name, current_tx_id, current_output_index)
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
CREATE OR REPLACE FUNCTION update_projected_nft_${validSQLName}() RETURNS TRIGGER AS $$
|
|
29
|
+
DECLARE
|
|
30
|
+
v_status TEXT;
|
|
31
|
+
BEGIN
|
|
32
|
+
IF NEW.payload_type = '${PrimitiveTypeCardanoProjectedNFT}'
|
|
33
|
+
AND NEW.primitive_name = '${name}' THEN
|
|
34
|
+
|
|
35
|
+
v_status := NEW.payload->>'status';
|
|
36
|
+
|
|
37
|
+
IF v_status = 'Claim' THEN
|
|
38
|
+
DELETE FROM ${intermediateTable}
|
|
39
|
+
WHERE primitive_name = NEW.primitive_name
|
|
40
|
+
AND current_tx_id = NEW.payload->>'previousTxId'
|
|
41
|
+
AND current_output_index = (NEW.payload->>'previousOutputIndex')::INTEGER;
|
|
42
|
+
ELSE
|
|
43
|
+
IF NEW.payload->>'previousTxId' IS NOT NULL
|
|
44
|
+
AND NEW.payload->>'previousTxId' != '' THEN
|
|
45
|
+
DELETE FROM ${intermediateTable}
|
|
46
|
+
WHERE primitive_name = NEW.primitive_name
|
|
47
|
+
AND current_tx_id = NEW.payload->>'previousTxId'
|
|
48
|
+
AND current_output_index = (NEW.payload->>'previousOutputIndex')::INTEGER;
|
|
49
|
+
END IF;
|
|
50
|
+
|
|
51
|
+
INSERT INTO ${intermediateTable} (
|
|
52
|
+
primitive_name, owner_address, current_tx_id, current_output_index,
|
|
53
|
+
previous_tx_id, previous_output_index, policy_id, asset_name,
|
|
54
|
+
status, for_how_long, block_height
|
|
55
|
+
) VALUES (
|
|
56
|
+
NEW.primitive_name,
|
|
57
|
+
NEW.payload->>'ownerAddress',
|
|
58
|
+
NEW.payload->>'currentTxId',
|
|
59
|
+
(NEW.payload->>'currentOutputIndex')::INTEGER,
|
|
60
|
+
NULLIF(NEW.payload->>'previousTxId', ''),
|
|
61
|
+
CASE WHEN NEW.payload->>'previousOutputIndex' IS NOT NULL
|
|
62
|
+
AND NEW.payload->>'previousOutputIndex' != ''
|
|
63
|
+
THEN (NEW.payload->>'previousOutputIndex')::INTEGER
|
|
64
|
+
ELSE NULL END,
|
|
65
|
+
NEW.payload->>'policyId',
|
|
66
|
+
NEW.payload->>'assetName',
|
|
67
|
+
v_status,
|
|
68
|
+
NULLIF(NEW.payload->>'forHowLong', ''),
|
|
69
|
+
NEW.effectstream_block_height
|
|
70
|
+
)
|
|
71
|
+
ON CONFLICT (primitive_name, current_tx_id, current_output_index) DO UPDATE SET
|
|
72
|
+
owner_address = EXCLUDED.owner_address,
|
|
73
|
+
previous_tx_id = EXCLUDED.previous_tx_id,
|
|
74
|
+
previous_output_index = EXCLUDED.previous_output_index,
|
|
75
|
+
status = EXCLUDED.status,
|
|
76
|
+
for_how_long = EXCLUDED.for_how_long,
|
|
77
|
+
block_height = EXCLUDED.block_height;
|
|
78
|
+
END IF;
|
|
79
|
+
END IF;
|
|
80
|
+
|
|
81
|
+
RETURN NEW;
|
|
82
|
+
END;
|
|
83
|
+
$$ LANGUAGE plpgsql;
|
|
84
|
+
|
|
85
|
+
CREATE TRIGGER trigger_update_projected_nft_${validSQLName}
|
|
86
|
+
AFTER INSERT ON effectstream.primitive_accounting
|
|
87
|
+
FOR EACH ROW
|
|
88
|
+
EXECUTE FUNCTION update_projected_nft_${validSQLName}();
|
|
89
|
+
|
|
90
|
+
SELECT pgivm.create_immv('primitives.cardano_projected_nft_view_${validSQLName}',
|
|
91
|
+
'SELECT
|
|
92
|
+
primitive_name,
|
|
93
|
+
owner_address,
|
|
94
|
+
current_tx_id,
|
|
95
|
+
current_output_index,
|
|
96
|
+
previous_tx_id,
|
|
97
|
+
previous_output_index,
|
|
98
|
+
policy_id,
|
|
99
|
+
asset_name,
|
|
100
|
+
status,
|
|
101
|
+
for_how_long
|
|
102
|
+
FROM ${intermediateTable};
|
|
103
|
+
');
|
|
104
|
+
`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export const PROJECTED_NFT_VIEW_PREFIX = "cardano_projected_nft_view_" as const;
|
|
108
|
+
export const PROJECTED_NFT_INTERMEDIATE_PREFIX = "cardano_projected_nft_intermediate_" as const;
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cardano Projected NFT Primitive
|
|
3
|
+
*
|
|
4
|
+
* Tracks the Lock → Unlock → Claim lifecycle of NFTs locked at a Plutus script
|
|
5
|
+
* (the "hololocker" from projected-nft-whirlpool). This enables "projecting" on-chain
|
|
6
|
+
* NFTs into off-chain game state while the NFT remains provably locked on Cardano.
|
|
7
|
+
*
|
|
8
|
+
* Data source: Dolos streams raw Cardano blocks via gRPC. Dolos resolves spent input
|
|
9
|
+
* references, so `tx.inputs[].asOutput` contains the full resolved UTxO including its
|
|
10
|
+
* inline datum (the hololocker State datum).
|
|
11
|
+
*
|
|
12
|
+
* Extraction: `getPayload()` performs three detection steps:
|
|
13
|
+
* 1. **Script inputs**: collects all `tx.inputs` whose resolved address contains the
|
|
14
|
+
* configured `scriptHash`. These are UTxOs being consumed from the hololocker.
|
|
15
|
+
* 2. **Script outputs** (Lock / Unlocking): iterates `tx.outputs` at the script address,
|
|
16
|
+
* parses the inline datum via `parseProjectedNftDatum()` to extract owner, status,
|
|
17
|
+
* and forHowLong. Matches each output to a consumed input (state transition) or
|
|
18
|
+
* treats it as a fresh Lock if no matching input exists.
|
|
19
|
+
* 3. **Claims**: any script inputs NOT matched to an output are Claims — the NFT left
|
|
20
|
+
* the script and returned to the owner's wallet.
|
|
21
|
+
*
|
|
22
|
+
* Datum parsing: the hololocker State datum is CBOR-encoded PlutusData with structure:
|
|
23
|
+
* State { owner: Owner(PKH|NFT|Receipt), status: Locked | Unlocking(out_ref, for_how_long) }
|
|
24
|
+
* The datum parser (`projected-nft-datum.ts`) walks the Constr tree to extract owner hash,
|
|
25
|
+
* status, and the time-lock expiry (forHowLong) for Unlocking states.
|
|
26
|
+
*
|
|
27
|
+
* Predicate: uses `has_address` with `payment_part: scriptHash`, so Dolos only sends
|
|
28
|
+
* transactions that interact with the hololocker script address.
|
|
29
|
+
*
|
|
30
|
+
* IVM: maintains a materialized view of NFT states at the script. The trigger UPSERTs
|
|
31
|
+
* on Lock/Unlocking events and DELETEs on Claim events, providing a real-time view of
|
|
32
|
+
* which NFTs are currently locked or unlocking.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import type { StaticDecode } from "@sinclair/typebox";
|
|
36
|
+
import {
|
|
37
|
+
type CommandTuple,
|
|
38
|
+
generateRawStmInput,
|
|
39
|
+
} from "@effectstream/concise";
|
|
40
|
+
import type {
|
|
41
|
+
ConfigSyncProtocolType,
|
|
42
|
+
FlattenSyncProtocolIOFor,
|
|
43
|
+
ProtocolPrimitiveMap,
|
|
44
|
+
} from "@effectstream/config";
|
|
45
|
+
import type { StateUpdateStream } from "@effectstream/coroutine";
|
|
46
|
+
import { type JsonObject, Primitive } from "@effectstream/sm";
|
|
47
|
+
import {
|
|
48
|
+
type AddressAndType,
|
|
49
|
+
AddressType,
|
|
50
|
+
type EffectstreamBlockNumber,
|
|
51
|
+
uint8ArrayToHexString,
|
|
52
|
+
} from "@effectstream/utils";
|
|
53
|
+
import { projectedNftGrammar } from "./projected-nft-grammar.ts";
|
|
54
|
+
import {
|
|
55
|
+
PROJECTED_NFT_INTERMEDIATE_PREFIX,
|
|
56
|
+
PROJECTED_NFT_VIEW_PREFIX,
|
|
57
|
+
projectedNftIvm,
|
|
58
|
+
} from "./projected-nft-ivm.ts";
|
|
59
|
+
import { PrimitiveTypeCardanoProjectedNFT } from "../builtin.ts";
|
|
60
|
+
import { addressToHex } from "../cardano-utils/cardano-helpers.ts";
|
|
61
|
+
import { parseProjectedNftDatum, type ProjectedNftDatum } from "./projected-nft-datum.ts";
|
|
62
|
+
|
|
63
|
+
export class CardanoProjectedNftPrimitive extends Primitive<
|
|
64
|
+
ConfigSyncProtocolType.CARDANO_UTXORPC_PARALLEL,
|
|
65
|
+
typeof projectedNftGrammar
|
|
66
|
+
> {
|
|
67
|
+
readonly internalTypeName = PrimitiveTypeCardanoProjectedNFT;
|
|
68
|
+
readonly scriptHash: string;
|
|
69
|
+
override grammar = projectedNftGrammar;
|
|
70
|
+
|
|
71
|
+
override dynamicTables = projectedNftIvm;
|
|
72
|
+
override getIntermediatePrefix(): string[] {
|
|
73
|
+
return [PROJECTED_NFT_INTERMEDIATE_PREFIX];
|
|
74
|
+
}
|
|
75
|
+
override getViewPrefix(): string[] {
|
|
76
|
+
return [PROJECTED_NFT_VIEW_PREFIX];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
constructor(config: {
|
|
80
|
+
instanceName: string;
|
|
81
|
+
startBlockHeight: number;
|
|
82
|
+
stateMachinePrefix: string | undefined;
|
|
83
|
+
scriptHash: string;
|
|
84
|
+
}) {
|
|
85
|
+
super(config);
|
|
86
|
+
this.scriptHash = config.scriptHash.toLowerCase();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
override getConfig(): ProtocolPrimitiveMap[ConfigSyncProtocolType.CARDANO_UTXORPC_PARALLEL] {
|
|
90
|
+
return {
|
|
91
|
+
name: this.instanceName,
|
|
92
|
+
type: this.internalTypeName,
|
|
93
|
+
startBlockHeight: this.startBlockHeight,
|
|
94
|
+
scheduledPrefix: this.stateMachinePrefix,
|
|
95
|
+
predicate: {
|
|
96
|
+
match: { cardano: { has_address: { payment_part: this.scriptHash } } },
|
|
97
|
+
},
|
|
98
|
+
} as const;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private isScriptAddress(address: Uint8Array): boolean {
|
|
102
|
+
const hex = addressToHex(address);
|
|
103
|
+
return hex.includes(this.scriptHash);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private extractNativeAssets(output: { assets: { policyId: Uint8Array; assets: { name: Uint8Array }[] }[] }): { policyId: string; assetName: string }[] {
|
|
107
|
+
const assets: { policyId: string; assetName: string }[] = [];
|
|
108
|
+
for (const ma of output.assets) {
|
|
109
|
+
const policyId = uint8ArrayToHexString(ma.policyId);
|
|
110
|
+
for (const asset of ma.assets) {
|
|
111
|
+
const assetName = uint8ArrayToHexString(asset.name);
|
|
112
|
+
assets.push({ policyId, assetName });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return assets;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private emitEntry(
|
|
119
|
+
parsed: ProjectedNftDatum,
|
|
120
|
+
previousTxId: string | null,
|
|
121
|
+
previousOutputIndex: number | null,
|
|
122
|
+
currentTxId: string,
|
|
123
|
+
currentOutputIndex: number | string,
|
|
124
|
+
policyId: string,
|
|
125
|
+
assetName: string,
|
|
126
|
+
statusOverride?: "Claim",
|
|
127
|
+
): {
|
|
128
|
+
fromAddressAndType: AddressAndType;
|
|
129
|
+
stateMachinePayload: any;
|
|
130
|
+
accountingPayload: JsonObject;
|
|
131
|
+
} {
|
|
132
|
+
const status = statusOverride ?? parsed.status;
|
|
133
|
+
const accountingPayload = {
|
|
134
|
+
ownerAddress: parsed.owner,
|
|
135
|
+
previousTxId: previousTxId ?? "",
|
|
136
|
+
previousOutputIndex: previousOutputIndex != null ? previousOutputIndex : "",
|
|
137
|
+
currentTxId,
|
|
138
|
+
currentOutputIndex,
|
|
139
|
+
policyId,
|
|
140
|
+
assetName,
|
|
141
|
+
status,
|
|
142
|
+
forHowLong: parsed.forHowLong ?? "",
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const stateMachinePayload: any = this.stateMachinePrefix
|
|
146
|
+
? generateRawStmInput(this.grammar, this.stateMachinePrefix, {
|
|
147
|
+
ownerAddress: parsed.owner,
|
|
148
|
+
previousTxId: previousTxId ?? "",
|
|
149
|
+
previousOutputIndex: previousOutputIndex != null ? String(previousOutputIndex) : "",
|
|
150
|
+
currentTxId,
|
|
151
|
+
currentOutputIndex: String(currentOutputIndex),
|
|
152
|
+
policyId,
|
|
153
|
+
assetName,
|
|
154
|
+
status,
|
|
155
|
+
forHowLong: parsed.forHowLong ?? "",
|
|
156
|
+
})
|
|
157
|
+
: null;
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
fromAddressAndType: { type: AddressType.NONE, address: "0x0" },
|
|
161
|
+
accountingPayload,
|
|
162
|
+
stateMachinePayload,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
override *getPayload(
|
|
167
|
+
_: EffectstreamBlockNumber,
|
|
168
|
+
primitiveTransactionData: FlattenSyncProtocolIOFor<ConfigSyncProtocolType.CARDANO_UTXORPC_PARALLEL>,
|
|
169
|
+
): StateUpdateStream<{
|
|
170
|
+
isBatched: boolean;
|
|
171
|
+
data: {
|
|
172
|
+
fromAddressAndType: AddressAndType;
|
|
173
|
+
stateMachinePayload: StaticDecode<CommandTuple<string, typeof projectedNftGrammar>> | null;
|
|
174
|
+
accountingPayload: JsonObject;
|
|
175
|
+
}[];
|
|
176
|
+
}> {
|
|
177
|
+
const tx = primitiveTransactionData.output.payload.tx;
|
|
178
|
+
const currentTxId = uint8ArrayToHexString(tx.hash);
|
|
179
|
+
|
|
180
|
+
const entries: {
|
|
181
|
+
fromAddressAndType: AddressAndType;
|
|
182
|
+
stateMachinePayload: any;
|
|
183
|
+
accountingPayload: JsonObject;
|
|
184
|
+
}[] = [];
|
|
185
|
+
|
|
186
|
+
// Build a set of script inputs (previous UTxOs being consumed)
|
|
187
|
+
const scriptInputs: Map<string, { txHash: string; outputIndex: number; resolved: any }> = new Map();
|
|
188
|
+
for (const input of tx.inputs) {
|
|
189
|
+
if (input.asOutput && this.isScriptAddress(input.asOutput.address)) {
|
|
190
|
+
const key = `${uint8ArrayToHexString(input.txHash)}#${input.outputIndex}`;
|
|
191
|
+
scriptInputs.set(key, {
|
|
192
|
+
txHash: uint8ArrayToHexString(input.txHash),
|
|
193
|
+
outputIndex: input.outputIndex,
|
|
194
|
+
resolved: input.asOutput,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Script outputs (new UTxOs at script address) — Lock or Unlocking events
|
|
200
|
+
for (let outputIndex = 0; outputIndex < tx.outputs.length; outputIndex++) {
|
|
201
|
+
const output = tx.outputs[outputIndex];
|
|
202
|
+
if (!this.isScriptAddress(output.address)) continue;
|
|
203
|
+
|
|
204
|
+
const parsed = parseProjectedNftDatum(output.datum?.payload);
|
|
205
|
+
if (!parsed) continue;
|
|
206
|
+
|
|
207
|
+
// Find a matching script input (state transition: Lock→Unlocking)
|
|
208
|
+
let previousTxId: string | null = null;
|
|
209
|
+
let previousOutputIndex: number | null = null;
|
|
210
|
+
|
|
211
|
+
for (const [key, inp] of scriptInputs) {
|
|
212
|
+
previousTxId = inp.txHash;
|
|
213
|
+
previousOutputIndex = inp.outputIndex;
|
|
214
|
+
scriptInputs.delete(key);
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Extract policyId/assetName from the output's native assets
|
|
219
|
+
const assets = this.extractNativeAssets(output);
|
|
220
|
+
for (const { policyId, assetName } of assets) {
|
|
221
|
+
entries.push(
|
|
222
|
+
this.emitEntry(parsed, previousTxId, previousOutputIndex, currentTxId, outputIndex, policyId, assetName),
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Remaining script inputs with no corresponding output = Claims
|
|
228
|
+
for (const [, inp] of scriptInputs) {
|
|
229
|
+
const parsed = parseProjectedNftDatum(inp.resolved.datum?.payload);
|
|
230
|
+
if (!parsed) continue;
|
|
231
|
+
|
|
232
|
+
const assets = this.extractNativeAssets(inp.resolved);
|
|
233
|
+
for (const { policyId, assetName } of assets) {
|
|
234
|
+
entries.push(
|
|
235
|
+
this.emitEntry(parsed, inp.txHash, inp.outputIndex, currentTxId, "", policyId, assetName, "Claim"),
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return { isBatched: true, data: entries };
|
|
241
|
+
}
|
|
242
|
+
}
|