Skip to content

Commit a13b73b

Browse files
committed
feat(projection): extend withHandles mappers to extract subhandle data
1 parent a450e03 commit a13b73b

File tree

3 files changed

+172
-15
lines changed

3 files changed

+172
-15
lines changed

packages/projection/src/operators/Mappers/withHandles.ts

+50-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Asset, Cardano, Handle } from '@cardano-sdk/core';
22
import { CIP67Asset, CIP67Assets, WithCIP67 } from './withCIP67';
33
import { FilterByPolicyIds } from './types';
4+
import { HexBlob } from '@cardano-sdk/util';
45
import { Logger } from 'ts-log';
56
import { ProjectionOperator } from '../../types';
67
import { assetNameToUTF8Handle } from './util';
@@ -19,6 +20,7 @@ export interface HandleOwnership {
1920
assetId: Cardano.AssetId;
2021
policyId: Cardano.PolicyId;
2122
datum?: Cardano.PlutusData;
23+
parentHandle?: Handle;
2224
}
2325

2426
export interface WithHandles {
@@ -27,7 +29,10 @@ export interface WithHandles {
2729

2830
const assetIdToUTF8Handle = (assetId: Cardano.AssetId, cip67Asset: CIP67Asset | undefined) => {
2931
if (cip67Asset) {
30-
if (cip67Asset.decoded.label === Asset.AssetNameLabelNum.UserNFT) {
32+
if (
33+
cip67Asset.decoded.label === Asset.AssetNameLabelNum.UserNFT ||
34+
cip67Asset.decoded.label === Asset.AssetNameLabelNum.VirtualHandle
35+
) {
3136
return Cardano.AssetName.toUTF8(cip67Asset.decoded.content);
3237
}
3338
// Ignore all but UserNFT cip67 assets
@@ -36,6 +41,36 @@ const assetIdToUTF8Handle = (assetId: Cardano.AssetId, cip67Asset: CIP67Asset |
3641
return assetNameToUTF8Handle(Cardano.AssetId.getAssetName(assetId));
3742
};
3843

44+
const getHandleMetadata = (handleDataFields: Cardano.PlutusList, logger: Logger) => {
45+
const data = handleDataFields.items[2];
46+
47+
if (Cardano.util.isPlutusMap(data)) {
48+
return Cardano.util.tryConvertPlutusMapToUtf8Record(data, logger);
49+
}
50+
};
51+
52+
const getVirtualSubhandleOwnerAddress = (datum: HandleOwnership['datum'], logger: Logger) => {
53+
if (datum && Cardano.util.isConstrPlutusData(datum)) {
54+
const resolvedAddressEntry = getHandleMetadata(datum.fields, logger);
55+
56+
if (
57+
resolvedAddressEntry?.resolved_addresses &&
58+
Cardano.util.isPlutusMap(resolvedAddressEntry?.resolved_addresses)
59+
) {
60+
const decodedResolvedAddresses = Cardano.util.tryConvertPlutusMapToUtf8Record(
61+
resolvedAddressEntry.resolved_addresses,
62+
logger
63+
);
64+
65+
if (Cardano.util.isPlutusBoundedBytes(decodedResolvedAddresses.ada)) {
66+
return Cardano.PaymentAddress(
67+
Cardano.Address.fromBytes(HexBlob.fromBytes(decodedResolvedAddresses.ada)).toBech32()
68+
);
69+
}
70+
}
71+
}
72+
};
73+
3974
const tryCreateHandleOwnership = (
4075
assetId: Cardano.AssetId,
4176
policyIds: Cardano.PolicyId[],
@@ -47,14 +82,26 @@ const tryCreateHandleOwnership = (
4782
if (!policyIds.includes(policyId)) return;
4883
try {
4984
const cip67Asset = cip67Assets.byAssetId[assetId];
85+
5086
const handle = assetIdToUTF8Handle(assetId, cip67Asset);
87+
const subhandleProps: Partial<HandleOwnership> = {};
5188
if (handle) {
89+
if (handle.includes('@')) {
90+
subhandleProps.parentHandle = handle.split('@')[1];
91+
92+
if (cip67Asset?.decoded.label === Asset.AssetNameLabelNum.VirtualHandle) {
93+
const virtualSubhandleOwnerAddress = getVirtualSubhandleOwnerAddress(txOut?.datum, logger);
94+
subhandleProps.latestOwnerAddress = virtualSubhandleOwnerAddress || null;
95+
}
96+
}
97+
5298
return {
5399
assetId,
54100
datum: txOut?.datum,
55101
handle,
56102
latestOwnerAddress: txOut?.address || null,
57-
policyId
103+
policyId,
104+
...subhandleProps
58105
};
59106
}
60107
} catch (error: unknown) {
@@ -117,6 +164,7 @@ export const withHandles =
117164
}),
118165
{} as Record<string, HandleOwnership>
119166
);
167+
120168
return {
121169
...evt,
122170
handles: [...Object.values(handleMap)]

packages/projection/test/operators/Mappers/handleUtil.ts

+44
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ export const maryAddress = Cardano.PaymentAddress(
1717
export const bobHandleOne = 'bob.handle.one';
1818
export const bobHandleTwo = 'bob.handle.two';
1919
export const maryHandleOne = 'mary.handle.one';
20+
export const virtualHandle = 'virtual@handl';
21+
export const NFTHandle = 'sub@handl';
2022
export const handleOutputs = {
2123
maryHandleToBob: {
2224
address: bobAddress,
@@ -75,6 +77,14 @@ export const referenceTokenAssetName = Asset.AssetNameLabel.encode(
7577
handleAssetName(maryHandleOne),
7678
Asset.AssetNameLabelNum.ReferenceNFT
7779
);
80+
export const virtualHandleAssetName = Asset.AssetNameLabel.encode(
81+
handleAssetName(virtualHandle),
82+
Asset.AssetNameLabelNum.VirtualHandle
83+
);
84+
export const subhandleAssetName = Asset.AssetNameLabel.encode(
85+
handleAssetName(NFTHandle),
86+
Asset.AssetNameLabelNum.UserNFT
87+
);
7888
export const scriptAddress = bobAddress;
7989
export const handleDatum = Serialization.PlutusData.fromCbor(
8090
HexBlob(
@@ -83,6 +93,18 @@ export const handleDatum = Serialization.PlutusData.fromCbor(
8393
)
8494
).toCore();
8595

96+
export const virtualSubhandleDatum = Serialization.PlutusData.fromCbor(
97+
HexBlob(
98+
'd8799faf446e616d654d247669727475616c40686e646c45696d6167655838697066733a2f2f7a623272686b52636a5471546e5a387462704635485a474e4c4e355473324554633558477039576264614b415134335472496d65646961547970654a696d6167652f6a706567426f6700496f675f6e756d6265720046726172697479456261736963466c656e6774680c4a63686172616374657273476c657474657273516e756d657269635f6d6f64696669657273404a7375625f7261726974794562617369634a7375625f6c656e677468074e7375625f63686172616374657273476c657474657273557375625f6e756d657269635f6d6f64696669657273404b68616e646c655f74797065517669727475616c5f73756268616e646c654776657273696f6e0101a94e7374616e646172645f696d6167655838697066733a2f2f7a623272686b52636a5471546e5a387462704635485a474e4c4e355473324554633558477039576264614b41513433547246706f7274616c404864657369676e65724047736f6369616c73404676656e646f72404764656661756c7400536c6173745f7570646174655f616464726573735839007ad324c4fb08709dd997f6b2ba7980d5007103a2aa3f7a7eb8b44bc6f1a8e379127b811583070faf74db00d880d45027fe6171b1b69bd9ca4c76616c6964617465645f6279581c4da965a049dfd15ed1ee19fba6e2974a0b79fc416dd1796a1f97f5e1527265736f6c7665645f616464726573736573a1436164615839007ad324c4fb08709dd997f6b2ba7980d5007103a2aa3f7a7eb8b44bc6f1a8e379127b811583070faf74db00d880d45027fe6171b1b69bd9caff'
99+
)
100+
).toCore();
101+
102+
export const NFTSubhandleDatum = Serialization.PlutusData.fromCbor(
103+
HexBlob(
104+
'd8799faa446e616d65492473756240686e646c45696d6167655838697066733a2f2f7a6232726862426e7a6e4e48716748624a58786d71596a47714663377947314a444e6741664d3534726472455032776366496d65646961547970654a696d6167652f6a706567426f6700496f675f6e756d6265720046726172697479456261736963466c656e677468084a63686172616374657273476c657474657273516e756d657269635f6d6f64696669657273404776657273696f6e0101af4e7374616e646172645f696d6167655838697066733a2f2f7a6232726862426e7a6e4e48716748624a58786d71596a47714663377947314a444e6741664d353472647245503277636646706f7274616c404864657369676e65724047736f6369616c73404676656e646f72404764656661756c7400536c6173745f7570646174655f61646472657373583900f541f0822d4794e6d1ddc3c0d5e932585bfcce2d869b1c2ee05b1dc7c37bace64b57b50a044bbafa593811a6f49c9d8d8c0b187932e2df404c76616c6964617465645f6279581c4da965a049dfd15ed1ee19fba6e2974a0b79fc416dd1796a1f97f5e14a696d6167655f68617368584034333831373362613630333931353466646232643137383763363765633636333863393462643331633835336630643964356166343365626462313864623934537374616e646172645f696d6167655f686173685840343338313733626136303339313534666462326431373837633637656336363338633934626433316338353366306439643561663433656264623138646239344b7376675f76657273696f6e45322e302e314c6167726565645f7465726d7340546d6967726174655f7369675f72657175697265640045747269616c00446e73667700ff'
105+
)
106+
).toCore();
107+
86108
export const userNftOutput: Cardano.TxOut = {
87109
address: maryAddress,
88110
value: {
@@ -99,3 +121,25 @@ export const referenceNftOutput: Cardano.TxOut = {
99121
coins: 123n
100122
}
101123
};
124+
125+
export const virtualSubHandleOutput: Cardano.TxOut = {
126+
address: Cardano.PaymentAddress(
127+
'addr_test1qqthrsf7x40ppfpq8ky53t6c694uu2eg6hu2q2p4kzepsyd8hqtvlesvrpvln3srklcvhu2r9z22fdhaxvh2m2pg3nuq3w2zfn'
128+
),
129+
datum: virtualSubhandleDatum,
130+
value: {
131+
assets: new Map([[Cardano.AssetId.fromParts(handlePolicyId, virtualHandleAssetName), 1n]]),
132+
coins: 123n
133+
}
134+
};
135+
136+
export const NFTSubHandleOutput: Cardano.TxOut = {
137+
address: Cardano.PaymentAddress(
138+
'addr_test1qqthrsf7x40ppfpq8ky53t6c694uu2eg6hu2q2p4kzepsyd8hqtvlesvrpvln3srklcvhu2r9z22fdhaxvh2m2pg3nuq3w2zfn'
139+
),
140+
datum: NFTSubhandleDatum,
141+
value: {
142+
assets: new Map([[Cardano.AssetId.fromParts(handlePolicyId, subhandleAssetName), 1n]]),
143+
coins: 123n
144+
}
145+
};

packages/projection/test/operators/Mappers/withHandles.test.ts

+78-13
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Asset, Cardano } from '@cardano-sdk/core';
22
import { Buffer } from 'buffer';
33
import { Mappers, ProjectionEvent } from '../../../src';
44
import {
5+
NFTSubHandleOutput,
56
assetIdFromHandle,
67
bobAddress,
78
bobHandleOne,
@@ -11,14 +12,32 @@ import {
1112
maryAddress,
1213
maryHandleOne,
1314
referenceNftOutput,
14-
userNftOutput
15+
subhandleAssetName,
16+
userNftOutput,
17+
virtualHandleAssetName,
18+
virtualSubHandleOutput
1519
} from './handleUtil';
1620
import { firstValueFrom, of } from 'rxjs';
1721
import { logger, mockProviders } from '@cardano-sdk/util-dev';
18-
import { withCIP67, withHandles, withUtxo } from '../../../src/operators/Mappers';
22+
import { withCIP67, withHandles, withMint, withUtxo } from '../../../src/operators/Mappers';
1923

2024
type In = Mappers.WithMint & Mappers.WithCIP67 & Mappers.WithNftMetadata;
2125

26+
const project = (tx: Cardano.OnChainTx) =>
27+
firstValueFrom(
28+
of({
29+
block: {
30+
body: [tx],
31+
header: mockProviders.ledgerTip
32+
}
33+
} as ProjectionEvent).pipe(
34+
withUtxo(),
35+
withMint(),
36+
withCIP67(),
37+
withHandles({ policyIds: [handlePolicyId] }, logger)
38+
)
39+
);
40+
2241
describe('withHandles', () => {
2342
it('sets "datum" property on the handle if utxo has datum', async () => {
2443
const datum = Buffer.from('123abc', 'hex');
@@ -246,17 +265,6 @@ describe('withHandles', () => {
246265
});
247266

248267
describe('cip68', () => {
249-
// eslint-disable-next-line unicorn/consistent-function-scoping
250-
const project = (tx: Cardano.OnChainTx) =>
251-
firstValueFrom(
252-
of({
253-
block: {
254-
body: [tx],
255-
header: mockProviders.ledgerTip
256-
}
257-
} as ProjectionEvent).pipe(withUtxo(), withCIP67(), withHandles({ policyIds: [handlePolicyId] }, logger))
258-
);
259-
260268
it('does not change ownership when only reference token is present', async () => {
261269
const { handles } = await project({
262270
body: { outputs: [referenceNftOutput] },
@@ -288,4 +296,61 @@ describe('withHandles', () => {
288296
expect(handles[0].latestOwnerAddress).toBe(maryAddress);
289297
});
290298
});
299+
300+
describe('subhandles', () => {
301+
it('adds parentHandle data for virtual subhandles', async () => {
302+
const { handles } = await project({
303+
body: { outputs: [virtualSubHandleOutput] },
304+
inputSource: Cardano.InputSource.inputs
305+
} as Cardano.OnChainTx);
306+
307+
expect(handles).toHaveLength(1);
308+
309+
expect(handles[0].latestOwnerAddress).toBe(
310+
'addr_test1qpadxfxylvy8p8wejlmt9wnesr2squgr524r77n7hz6yh3h34r3hjynmsy2cxpc04a6dkqxcsr29qfl7v9cmrd5mm89qqh563f'
311+
);
312+
expect(handles[0].parentHandle).toBe('handl');
313+
expect(handles[0].handle).toBe('virtual@handl');
314+
});
315+
316+
it('adds parentHandle data for NFT subhandles', async () => {
317+
const { handles } = await project({
318+
body: { outputs: [NFTSubHandleOutput] },
319+
inputSource: Cardano.InputSource.inputs
320+
} as Cardano.OnChainTx);
321+
322+
expect(handles).toHaveLength(1);
323+
324+
expect(handles[0].parentHandle).toBe('handl');
325+
expect(handles[0].handle).toBe('sub@handl');
326+
});
327+
328+
it('includes a handle with "null" address, when transaction burns a subhandle', async () => {
329+
const { handles } = await project({
330+
body: {
331+
mint: new Map([[Cardano.AssetId.fromParts(handlePolicyId, subhandleAssetName), -1n]]),
332+
outputs: [] as Cardano.TxOut[]
333+
},
334+
inputSource: Cardano.InputSource.inputs
335+
} as Cardano.OnChainTx);
336+
337+
expect(handles.length).toBe(1);
338+
expect(handles[0].latestOwnerAddress).toBeNull();
339+
expect(handles[0].handle).toBe('sub@handl');
340+
});
341+
342+
it('includes a handle with "null" address, when transaction burns a virtualSubhandle', async () => {
343+
const { handles } = await project({
344+
body: {
345+
mint: new Map([[Cardano.AssetId.fromParts(handlePolicyId, virtualHandleAssetName), -1n]]),
346+
outputs: [] as Cardano.TxOut[]
347+
},
348+
inputSource: Cardano.InputSource.inputs
349+
} as Cardano.OnChainTx);
350+
351+
expect(handles.length).toBe(1);
352+
expect(handles[0].latestOwnerAddress).toBeNull();
353+
expect(handles[0].handle).toBe('virtual@handl');
354+
});
355+
});
291356
});

0 commit comments

Comments
 (0)