Skip to content

Commit cd381a9

Browse files
authored
fix: detect name transfers and renewals in special circumstances (#1303)
* fix: take new owner from nft event * fix: remove check on nft_custody view as it is no longer required * fix: name-renewal with no zonefile hash * fix: import v1 data during replay * fix: headers already sent in redirect * fix: import names first, subdomains last
1 parent 00e7197 commit cd381a9

File tree

6 files changed

+456
-83
lines changed

6 files changed

+456
-83
lines changed

src/api/routes/bns/names.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,6 @@ export function createBnsNamesRouter(db: DataStore, chainId: ChainID): express.R
9898
return;
9999
}
100100
res.redirect(`${resolverResult.result}/v1/names${req.url}`);
101-
next();
102101
return;
103102
}
104103
res.status(404).json({ error: `cannot find subdomain ${name}` });

src/datastore/postgres-store.ts

Lines changed: 60 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1491,12 +1491,12 @@ export class PgDataStore
14911491
for (const smartContract of entry.smartContracts) {
14921492
await this.updateSmartContract(client, entry.tx, smartContract);
14931493
}
1494-
for (const bnsName of entry.names) {
1495-
await this.updateNames(client, entry.tx, bnsName);
1496-
}
14971494
for (const namespace of entry.namespaces) {
14981495
await this.updateNamespaces(client, entry.tx, namespace);
14991496
}
1497+
for (const bnsName of entry.names) {
1498+
await this.updateNames(client, entry.tx, bnsName);
1499+
}
15001500
}
15011501
await this.refreshNftCustody(client, batchedTxData);
15021502
await this.refreshMaterializedView(client, 'chain_tip');
@@ -1701,12 +1701,12 @@ export class PgDataStore
17011701
for (const smartContract of entry.smartContracts) {
17021702
await this.updateSmartContract(client, entry.tx, smartContract);
17031703
}
1704-
for (const bnsName of entry.names) {
1705-
await this.updateNames(client, entry.tx, bnsName);
1706-
}
17071704
for (const namespace of entry.namespaces) {
17081705
await this.updateNamespaces(client, entry.tx, namespace);
17091706
}
1707+
for (const bnsName of entry.names) {
1708+
await this.updateNames(client, entry.tx, bnsName);
1709+
}
17101710
}
17111711
}
17121712

@@ -6896,8 +6896,59 @@ export class PgDataStore
68966896
status,
68976897
canonical,
68986898
} = bnsName;
6899-
// inserting remaining names information in names table
6900-
const validZonefileHash = this.validateZonefileHash(zonefile_hash);
6899+
// Try to figure out the name's expiration block based on its namespace's lifetime. However, if
6900+
// the name was only transferred, keep the expiration from the last register/renewal we had.
6901+
let expireBlock = expire_block;
6902+
if (status === 'name-transfer') {
6903+
const prevExpiration = await client.query<{ expire_block: number }>(
6904+
`SELECT expire_block
6905+
FROM names
6906+
WHERE name = $1
6907+
AND canonical = TRUE AND microblock_canonical = TRUE
6908+
ORDER BY registered_at DESC, microblock_sequence DESC, tx_index DESC
6909+
LIMIT 1`,
6910+
[name]
6911+
);
6912+
if (prevExpiration.rowCount > 0) {
6913+
expireBlock = prevExpiration.rows[0].expire_block;
6914+
}
6915+
} else {
6916+
const namespaceLifetime = await client.query<{ lifetime: number }>(
6917+
`SELECT lifetime
6918+
FROM namespaces
6919+
WHERE namespace_id = $1
6920+
AND canonical = true AND microblock_canonical = true
6921+
ORDER BY namespace_id, ready_block DESC, microblock_sequence DESC, tx_index DESC
6922+
LIMIT 1`,
6923+
[namespace_id]
6924+
);
6925+
if (namespaceLifetime.rowCount > 0) {
6926+
expireBlock = registered_at + namespaceLifetime.rows[0].lifetime;
6927+
}
6928+
}
6929+
// If we didn't receive a zonefile, keep the last valid one.
6930+
let finalZonefile = zonefile;
6931+
let finalZonefileHash = zonefile_hash;
6932+
if (finalZonefileHash === '') {
6933+
const lastZonefile = await client.query<{ zonefile: string; zonefile_hash: string }>(
6934+
`
6935+
SELECT z.zonefile, z.zonefile_hash
6936+
FROM zonefiles AS z
6937+
INNER JOIN names AS n USING (name, tx_id, index_block_hash)
6938+
WHERE z.name = $1
6939+
AND n.canonical = TRUE
6940+
AND n.microblock_canonical = TRUE
6941+
ORDER BY n.registered_at DESC, n.microblock_sequence DESC, n.tx_index DESC
6942+
LIMIT 1
6943+
`,
6944+
[name]
6945+
);
6946+
if (lastZonefile.rowCount > 0) {
6947+
finalZonefile = lastZonefile.rows[0].zonefile;
6948+
finalZonefileHash = lastZonefile.rows[0].zonefile_hash;
6949+
}
6950+
}
6951+
const validZonefileHash = this.validateZonefileHash(finalZonefileHash);
69016952
await client.query(
69026953
`
69036954
INSERT INTO zonefiles (name, zonefile, zonefile_hash, tx_id, index_block_hash)
@@ -6907,26 +6958,12 @@ export class PgDataStore
69076958
`,
69086959
[
69096960
name,
6910-
zonefile,
6961+
finalZonefile,
69116962
validZonefileHash,
69126963
hexToBuffer(tx_id),
69136964
hexToBuffer(blockData.index_block_hash),
69146965
]
69156966
);
6916-
// Try to figure out the name's expiration block based on its namespace's lifetime.
6917-
const namespaceLifetime = await client.query<{ lifetime: number }>(
6918-
`SELECT lifetime
6919-
FROM namespaces
6920-
WHERE namespace_id = $1
6921-
AND canonical = true AND microblock_canonical = true
6922-
ORDER BY namespace_id, ready_block DESC, microblock_sequence DESC, tx_index DESC
6923-
LIMIT 1`,
6924-
[namespace_id]
6925-
);
6926-
const expireBlock =
6927-
namespaceLifetime.rowCount > 0
6928-
? registered_at + namespaceLifetime.rows[0].lifetime
6929-
: expire_block;
69306967
await client.query(
69316968
`
69326969
INSERT INTO names(
@@ -7221,32 +7258,6 @@ export class PgDataStore
72217258
if (nameZonefile.rowCount === 0) {
72227259
return;
72237260
}
7224-
// The `names` and `zonefiles` tables only track latest zonefile changes. We need to check
7225-
// `nft_custody` for the latest name owner, but only for names that were NOT imported from v1
7226-
// since they did not generate an NFT event for us to track.
7227-
if (nameZonefile.rows[0].registered_at !== 1) {
7228-
let value: Buffer;
7229-
try {
7230-
value = bnsNameCV(name);
7231-
} catch (error) {
7232-
return;
7233-
}
7234-
const nameCustody = await client.query<{ recipient: string }>(
7235-
`
7236-
SELECT recipient
7237-
FROM ${includeUnanchored ? 'nft_custody_unanchored' : 'nft_custody'}
7238-
WHERE asset_identifier = $1 AND value = $2
7239-
`,
7240-
[getBnsSmartContractId(chainId), value]
7241-
);
7242-
if (nameCustody.rowCount === 0) {
7243-
return;
7244-
}
7245-
return {
7246-
...nameZonefile.rows[0],
7247-
address: nameCustody.rows[0].recipient,
7248-
};
7249-
}
72507261
return nameZonefile.rows[0];
72517262
});
72527263
if (queryResult) {

src/event-stream/bns/bns-helpers.ts

Lines changed: 59 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
1-
import { ChainID, ClarityType, hexToCV } from '@stacks/transactions';
1+
import { BufferCV, ChainID, ClarityType, hexToCV, StringAsciiCV } from '@stacks/transactions';
22
import { hexToBuffer, hexToUtf8String } from '../../helpers';
3-
import { CoreNodeParsedTxMessage } from '../../event-stream/core-node-message';
3+
import {
4+
CoreNodeEvent,
5+
CoreNodeEventType,
6+
CoreNodeParsedTxMessage,
7+
NftTransferEvent,
8+
} from '../../event-stream/core-node-message';
49
import { getCoreNodeEndpoint } from '../../core-rpc/client';
510
import { StacksMainnet, StacksTestnet } from '@stacks/network';
611
import { URIType } from 'zone-file/dist/zoneFile';
@@ -244,10 +249,48 @@ function isEventFromBnsContract(event: SmartContractEvent): boolean {
244249
);
245250
}
246251

252+
export function parseNameRenewalWithNoZonefileHashFromContractCall(
253+
tx: CoreNodeParsedTxMessage,
254+
chainId: ChainID
255+
): DbBnsName | undefined {
256+
const payload = tx.parsed_tx.payload;
257+
if (
258+
payload.type_id === TxPayloadTypeID.ContractCall &&
259+
payload.function_name === 'name-renewal' &&
260+
getBnsContractID(chainId) === `${payload.address}.${payload.contract_name}` &&
261+
payload.function_args.length === 5 &&
262+
hexToCV(payload.function_args[4].hex).type === ClarityType.OptionalNone
263+
) {
264+
const namespace = (hexToCV(payload.function_args[0].hex) as BufferCV).buffer.toString('utf8');
265+
const name = (hexToCV(payload.function_args[1].hex) as BufferCV).buffer.toString('utf8');
266+
return {
267+
name: `${name}.${namespace}`,
268+
namespace_id: namespace,
269+
// NOTE: We're not using the `new_owner` argument here because there's a bug in the BNS
270+
// contract that doesn't actually transfer the name to the given principal:
271+
// https://github.com/stacks-network/stacks-blockchain/issues/2680, maybe this will be fixed
272+
// in Stacks 2.1
273+
address: tx.sender_address,
274+
// expire_block will be calculated upon DB insert based on the namespace's lifetime.
275+
expire_block: 0,
276+
registered_at: tx.block_height,
277+
// Since we received no zonefile_hash, the previous one will be reused when writing to DB.
278+
zonefile_hash: '',
279+
zonefile: '',
280+
tx_id: tx.parsed_tx.tx_id,
281+
tx_index: tx.core_tx.tx_index,
282+
status: 'name-renewal',
283+
canonical: true,
284+
};
285+
}
286+
}
287+
247288
export function parseNameFromContractEvent(
248289
event: SmartContractEvent,
249290
tx: CoreNodeParsedTxMessage,
250-
blockHeight: number
291+
txEvents: CoreNodeEvent[],
292+
blockHeight: number,
293+
chainId: ChainID
251294
): DbBnsName | undefined {
252295
if (!isEventFromBnsContract(event)) {
253296
return;
@@ -259,19 +302,21 @@ export function parseNameFromContractEvent(
259302
return;
260303
}
261304
let name_address = attachment.attachment.metadata.tx_sender.address;
262-
// Is this a `name-transfer` contract call? If so, record the new owner.
263-
if (
264-
attachment.attachment.metadata.op === 'name-transfer' &&
265-
tx.parsed_tx.payload.type_id === TxPayloadTypeID.ContractCall &&
266-
tx.parsed_tx.payload.function_args.length >= 3 &&
267-
tx.parsed_tx.payload.function_args[2].type_id === ClarityTypeID.PrincipalStandard
268-
) {
269-
const decoded = decodeClarityValue(tx.parsed_tx.payload.function_args[2].hex);
270-
const principal = decoded as ClarityValuePrincipalStandard;
271-
name_address = principal.address;
305+
// Is this a `name-transfer`? If so, look for the new owner in an `nft_transfer` event bundled in
306+
// the same transaction.
307+
if (attachment.attachment.metadata.op === 'name-transfer') {
308+
for (const txEvent of txEvents) {
309+
if (
310+
txEvent.type === CoreNodeEventType.NftTransferEvent &&
311+
txEvent.nft_transfer_event.asset_identifier === `${getBnsContractID(chainId)}::names`
312+
) {
313+
name_address = txEvent.nft_transfer_event.recipient;
314+
break;
315+
}
316+
}
272317
}
273318
const name: DbBnsName = {
274-
name: attachment.attachment.metadata.name.concat('.', attachment.attachment.metadata.namespace),
319+
name: `${attachment.attachment.metadata.name}.${attachment.attachment.metadata.namespace}`,
275320
namespace_id: attachment.attachment.metadata.namespace,
276321
address: name_address,
277322
// expire_block will be calculated upon DB insert based on the namespace's lifetime.

src/event-stream/core-node-message.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ export interface StxLockEvent extends CoreNodeEventBase {
7676
};
7777
}
7878

79-
interface NftTransferEvent extends CoreNodeEventBase {
79+
export interface NftTransferEvent extends CoreNodeEventBase {
8080
type: CoreNodeEventType.NftTransferEvent;
8181
nft_transfer_event: {
8282
/** Fully qualified asset ID, e.g. "ST2ZRX0K27GW0SP3GJCEMHD95TQGJMKB7G9Y0X1MH.contract-name.asset-name" */

src/event-stream/event-server.ts

Lines changed: 47 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,11 @@ import {
6363
} from 'stacks-encoding-native-js';
6464
import { ChainID } from '@stacks/transactions';
6565
import { BnsContractIdentifier } from './bns/bns-constants';
66-
import { parseNameFromContractEvent, parseNamespaceFromContractEvent } from './bns/bns-helpers';
66+
import {
67+
parseNameFromContractEvent,
68+
parseNameRenewalWithNoZonefileHashFromContractCall,
69+
parseNamespaceFromContractEvent,
70+
} from './bns/bns-helpers';
6771

6872
async function handleRawEventRequest(
6973
eventPath: string,
@@ -199,10 +203,15 @@ async function handleMicroblockMessage(
199203
});
200204
const updateData: DataStoreMicroblockUpdateData = {
201205
microblocks: dbMicroblocks,
202-
txs: parseDataStoreTxEventData(parsedTxs, msg.events, {
203-
block_height: -1, // TODO: fill during initial db insert
204-
index_block_hash: '',
205-
}),
206+
txs: parseDataStoreTxEventData(
207+
parsedTxs,
208+
msg.events,
209+
{
210+
block_height: -1, // TODO: fill during initial db insert
211+
index_block_hash: '',
212+
},
213+
chainId
214+
),
206215
};
207216
await db.updateMicroblocks(updateData);
208217
}
@@ -299,7 +308,7 @@ async function handleBlockMessage(
299308
block: dbBlock,
300309
microblocks: dbMicroblocks,
301310
minerRewards: dbMinerRewards,
302-
txs: parseDataStoreTxEventData(parsedTxs, msg.events, msg),
311+
txs: parseDataStoreTxEventData(parsedTxs, msg.events, msg, chainId),
303312
};
304313

305314
await db.update(dbData);
@@ -311,7 +320,8 @@ function parseDataStoreTxEventData(
311320
blockData: {
312321
block_height: number;
313322
index_block_hash: string;
314-
}
323+
},
324+
chainId: ChainID
315325
): DataStoreTxEventData[] {
316326
const dbData: DataStoreTxEventData[] = parsedTxs.map(tx => {
317327
const dbTx: DataStoreBlockUpdateData['txs'][number] = {
@@ -325,16 +335,29 @@ function parseDataStoreTxEventData(
325335
names: [],
326336
namespaces: [],
327337
};
328-
if (tx.parsed_tx.payload.type_id === TxPayloadTypeID.SmartContract) {
329-
const contractId = `${tx.sender_address}.${tx.parsed_tx.payload.contract_name}`;
330-
dbTx.smartContracts.push({
331-
tx_id: tx.core_tx.txid,
332-
contract_id: contractId,
333-
block_height: blockData.block_height,
334-
source_code: tx.parsed_tx.payload.code_body,
335-
abi: JSON.stringify(tx.core_tx.contract_abi),
336-
canonical: true,
337-
});
338+
switch (tx.parsed_tx.payload.type_id) {
339+
case TxPayloadTypeID.SmartContract:
340+
const contractId = `${tx.sender_address}.${tx.parsed_tx.payload.contract_name}`;
341+
dbTx.smartContracts.push({
342+
tx_id: tx.core_tx.txid,
343+
contract_id: contractId,
344+
block_height: blockData.block_height,
345+
source_code: tx.parsed_tx.payload.code_body,
346+
abi: JSON.stringify(tx.core_tx.contract_abi),
347+
canonical: true,
348+
});
349+
break;
350+
case TxPayloadTypeID.ContractCall:
351+
// Name renewals can happen without a zonefile_hash. In that case, the BNS contract does NOT
352+
// emit a `name-renewal` contract log, causing us to miss this event. This function catches
353+
// those cases.
354+
const name = parseNameRenewalWithNoZonefileHashFromContractCall(tx, chainId);
355+
if (name) {
356+
dbTx.names.push(name);
357+
}
358+
break;
359+
default:
360+
break;
338361
}
339362
return dbTx;
340363
});
@@ -372,7 +395,13 @@ function parseDataStoreTxEventData(
372395
if (!parsedTx) {
373396
throw new Error(`Unexpected missing tx during BNS parsing by tx_id ${event.txid}`);
374397
}
375-
const name = parseNameFromContractEvent(event, parsedTx, blockData.block_height);
398+
const name = parseNameFromContractEvent(
399+
event,
400+
parsedTx,
401+
events,
402+
blockData.block_height,
403+
chainId
404+
);
376405
if (name) {
377406
dbTx.names.push(name);
378407
}

0 commit comments

Comments
 (0)