Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ describe("runRegisterVoiceAssetWorkflow", () => {
owner: "0x0000000000000000000000000000000000000001",
},
}),
getTokenId: vi.fn().mockResolvedValue({
statusCode: 200,
body: "104",
}),
updateBasicAcousticFeatures: vi.fn(),
getBasicAcousticFeatures: vi.fn(),
};
Expand Down Expand Up @@ -82,12 +86,14 @@ describe("runRegisterVoiceAssetWorkflow", () => {
voiceHash: registrationBody.result,
owner: "0x0000000000000000000000000000000000000001",
},
tokenId: "104",
},
metadataUpdate: null,
voiceHash: registrationBody.result,
summary: {
owner: null,
hasFeatures: false,
tokenId: "104",
},
});
expect(service.registerVoiceAssetForCaller).not.toHaveBeenCalled();
Expand Down Expand Up @@ -125,6 +131,13 @@ describe("runRegisterVoiceAssetWorkflow", () => {
},
};
}),
getTokenId: vi.fn().mockImplementation(async () => {
sequence.push("read-token-id");
return {
statusCode: 200,
body: "205",
};
}),
updateBasicAcousticFeatures: vi.fn().mockImplementation(async () => {
sequence.push("update-features");
return { statusCode: 202, body: metadataBody };
Expand Down Expand Up @@ -156,6 +169,7 @@ describe("runRegisterVoiceAssetWorkflow", () => {
"register",
"wait-registration",
"read-voice",
"read-token-id",
"update-features",
"wait-metadata",
"read-features",
Expand All @@ -180,6 +194,7 @@ describe("runRegisterVoiceAssetWorkflow", () => {
voiceHash,
owner: "0x00000000000000000000000000000000000000aa",
},
tokenId: "205",
},
metadataUpdate: {
submission: metadataBody,
Expand All @@ -190,6 +205,7 @@ describe("runRegisterVoiceAssetWorkflow", () => {
summary: {
owner: "0x00000000000000000000000000000000000000aa",
hasFeatures: true,
tokenId: "205",
},
});
expect(service.registerVoiceAsset).not.toHaveBeenCalled();
Expand All @@ -203,6 +219,7 @@ describe("runRegisterVoiceAssetWorkflow", () => {
}),
registerVoiceAssetForCaller: vi.fn(),
getVoiceAsset: vi.fn(),
getTokenId: vi.fn(),
updateBasicAcousticFeatures: vi.fn(),
getBasicAcousticFeatures: vi.fn(),
};
Expand All @@ -225,15 +242,18 @@ describe("runRegisterVoiceAssetWorkflow", () => {
},
txHash: "0xreceipt-registration",
voiceAsset: null,
tokenId: null,
},
metadataUpdate: null,
voiceHash: null,
summary: {
owner: null,
hasFeatures: true,
tokenId: null,
},
});
expect(service.getVoiceAsset).not.toHaveBeenCalled();
expect(service.getTokenId).not.toHaveBeenCalled();
expect(service.updateBasicAcousticFeatures).not.toHaveBeenCalled();
expect(service.getBasicAcousticFeatures).not.toHaveBeenCalled();
});
Expand All @@ -258,6 +278,15 @@ describe("runRegisterVoiceAssetWorkflow", () => {
statusCode: 200,
body: { voiceHash, owner: "0x0000000000000000000000000000000000000001" },
}),
getTokenId: vi.fn()
.mockResolvedValueOnce({
statusCode: 404,
body: { error: "not ready" },
})
.mockResolvedValueOnce({
statusCode: 200,
body: "309",
}),
updateBasicAcousticFeatures: vi.fn().mockResolvedValue({
statusCode: 202,
body: { txHash: "0xmeta-retry" },
Expand All @@ -284,13 +313,48 @@ describe("runRegisterVoiceAssetWorkflow", () => {
});

expect(service.getVoiceAsset).toHaveBeenCalledTimes(2);
expect(service.getTokenId).toHaveBeenCalledTimes(2);
expect(service.getBasicAcousticFeatures).toHaveBeenCalledTimes(2);
expect(result.metadataUpdate).toMatchObject({
txHash: "0xreceipt-metadata",
features,
});
});

it("retries after transient token-id read errors before succeeding", async () => {
const voiceHash = "0x6666666666666666666666666666666666666666666666666666666666666666";
const service = {
registerVoiceAsset: vi.fn().mockResolvedValue({
statusCode: 202,
body: { txHash: "0xreg-transient", result: voiceHash },
}),
registerVoiceAssetForCaller: vi.fn(),
getVoiceAsset: vi.fn().mockResolvedValue({
statusCode: 200,
body: { voiceHash, owner: "0x0000000000000000000000000000000000000001" },
}),
getTokenId: vi.fn()
.mockRejectedValueOnce(new Error("rpc unavailable"))
.mockResolvedValueOnce({
statusCode: 200,
body: 412n,
}),
updateBasicAcousticFeatures: vi.fn(),
getBasicAcousticFeatures: vi.fn(),
};
mocks.createVoiceAssetsPrimitiveService.mockReturnValue(service);
mocks.waitForWorkflowWriteReceipt.mockResolvedValue("0xreceipt-registration");

const result = await runRegisterVoiceAssetWorkflow(context, auth, undefined, {
ipfsHash: "QmTransient",
royaltyRate: "100",
});

expect(service.getTokenId).toHaveBeenCalledTimes(2);
expect(result.registration.tokenId).toBe("412");
expect(result.summary.tokenId).toBe("412");
});

it("throws when registration readback never stabilizes", async () => {
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout").mockImplementation(((callback: TimerHandler) => {
if (typeof callback === "function") {
Expand All @@ -311,6 +375,7 @@ describe("runRegisterVoiceAssetWorkflow", () => {
statusCode: 404,
body: { error: "not ready" },
}),
getTokenId: vi.fn(),
updateBasicAcousticFeatures: vi.fn(),
getBasicAcousticFeatures: vi.fn(),
};
Expand All @@ -321,7 +386,43 @@ describe("runRegisterVoiceAssetWorkflow", () => {
ipfsHash: "QmTimeout",
royaltyRate: "100",
})).rejects.toThrow("registerVoiceAsset.registrationRead readback timeout");
expect(service.getVoiceAsset).toHaveBeenCalledTimes(20);
expect(service.getVoiceAsset).toHaveBeenCalledTimes(40);
setTimeoutSpy.mockRestore();
});

it("surfaces transient read errors after token-id retries are exhausted", async () => {
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout").mockImplementation(((callback: TimerHandler) => {
if (typeof callback === "function") {
callback();
}
return 0 as ReturnType<typeof setTimeout>;
}) as typeof setTimeout);
const voiceHash = "0x7777777777777777777777777777777777777777777777777777777777777777";
const service = {
registerVoiceAsset: vi.fn().mockResolvedValue({
statusCode: 202,
body: {
txHash: "0xreg-token-timeout",
result: voiceHash,
},
}),
registerVoiceAssetForCaller: vi.fn(),
getVoiceAsset: vi.fn().mockResolvedValue({
statusCode: 200,
body: { voiceHash, owner: "0x0000000000000000000000000000000000000001" },
}),
getTokenId: vi.fn().mockRejectedValue(new Error("rpc unavailable")),
updateBasicAcousticFeatures: vi.fn(),
getBasicAcousticFeatures: vi.fn(),
};
mocks.createVoiceAssetsPrimitiveService.mockReturnValue(service);
mocks.waitForWorkflowWriteReceipt.mockResolvedValue("0xreceipt-registration");

await expect(runRegisterVoiceAssetWorkflow(context, auth, undefined, {
ipfsHash: "QmTokenTimeout",
royaltyRate: "100",
})).rejects.toThrow("registerVoiceAsset.tokenIdRead readback timeout after transient read errors: rpc unavailable");
expect(service.getTokenId).toHaveBeenCalledTimes(40);
setTimeoutSpy.mockRestore();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,19 @@ export async function runRegisterVoiceAssetWorkflow(
"registerVoiceAsset.registrationRead",
)
: null;
const tokenIdRead = voiceHash
? await waitForWorkflowReadback(
() => voiceAssets.getTokenId({
auth,
api: { executionSource: "live", gaslessMode: "none" },
walletAddress,
wireParams: [voiceHash],
}),
(result) => result.statusCode === 200 && isNumericLike(result.body),
"registerVoiceAsset.tokenIdRead",
)
: null;
const tokenId = numericLikeToString(tokenIdRead?.body);

let metadataUpdate: import("../../../shared/route-types.js").RouteResult | null = null;
let metadataUpdateTxHash: string | null = null;
Expand Down Expand Up @@ -78,6 +91,7 @@ export async function runRegisterVoiceAssetWorkflow(
submission: registration.body,
txHash: registrationTxHash,
voiceAsset: registrationRead?.body ?? null,
tokenId,
},
metadataUpdate: metadataUpdate
? {
Expand All @@ -90,6 +104,7 @@ export async function runRegisterVoiceAssetWorkflow(
summary: {
owner: body.owner ?? null,
hasFeatures: Boolean(body.features),
tokenId,
},
};
}
Expand All @@ -100,13 +115,29 @@ async function waitForWorkflowReadback(
label: string,
) {
let lastResult: import("../../../shared/route-types.js").RouteResult | null = null;
let lastError: unknown = null;
for (let attempt = 0; attempt < 40; attempt += 1) {
const result = await read();
lastResult = result;
if (ready(result)) {
return result;
try {
const result = await read();
lastResult = result;
if (ready(result)) {
return result;
}
} catch (error) {
lastError = error;
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
if (lastError) {
throw new Error(`${label} readback timeout after transient read errors: ${String((lastError as Error)?.message ?? lastError)}`);
}
throw new Error(`${label} readback timeout: ${JSON.stringify(lastResult?.body ?? null)}`);
}

function isNumericLike(value: unknown): boolean {
return typeof value === "string" || typeof value === "number" || typeof value === "bigint";
}

function numericLikeToString(value: unknown): string | null {
return isNumericLike(value) ? String(value) : null;
}
2 changes: 2 additions & 0 deletions packages/api/src/workflows/commercialize-voice-asset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ const actorOverrideSchema = z.object({
walletAddress: z.string().regex(/^0x[a-fA-F0-9]{40}$/u).optional(),
});

// Commercialization requires the acting account to be the current on-chain owner
// of every asset being packaged. Roles/authorizations do not substitute ownership.
export const commercializeVoiceAssetWorkflowSchema = z.object({
packaging: createDatasetAndListForSaleSchema,
inspectListing: z.boolean().default(false),
Expand Down
Loading