From aa96eea381ab5eaef3d4c7f0a139371765aa628e Mon Sep 17 00:00:00 2001 From: Maharshi Mishra Date: Fri, 8 May 2026 18:27:53 +0530 Subject: [PATCH 1/3] fix sdk acknowledge without home channel --- sdk/ts/package-lock.json | 4 +- sdk/ts/package.json | 2 +- sdk/ts/src/client.ts | 13 ++-- sdk/ts/test/unit/client.test.ts | 111 ++++++++++++++++++++++++++++++++ 4 files changed, 120 insertions(+), 10 deletions(-) diff --git a/sdk/ts/package-lock.json b/sdk/ts/package-lock.json index ae51a7059..a432eaefe 100644 --- a/sdk/ts/package-lock.json +++ b/sdk/ts/package-lock.json @@ -1,12 +1,12 @@ { "name": "@yellow-org/sdk", - "version": "1.2.1", + "version": "1.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@yellow-org/sdk", - "version": "1.2.1", + "version": "1.2.2", "license": "MIT", "dependencies": { "abitype": "^1.2.3", diff --git a/sdk/ts/package.json b/sdk/ts/package.json index 9dba7c92e..e50452c36 100644 --- a/sdk/ts/package.json +++ b/sdk/ts/package.json @@ -1,6 +1,6 @@ { "name": "@yellow-org/sdk", - "version": "1.2.1", + "version": "1.2.2", "description": "The Yellow SDK empowers developers to build high-performance, scalable web3 applications using state channels. It's designed to provide near-instant transactions and significantly improved user experiences by minimizing direct blockchain interactions.", "type": "module", "main": "dist/index.js", diff --git a/sdk/ts/src/client.ts b/sdk/ts/src/client.ts index 8656ceeb5..79797c0e7 100644 --- a/sdk/ts/src/client.ts +++ b/sdk/ts/src/client.ts @@ -639,7 +639,8 @@ export class Client { * or to acknowledge channel creation without a deposit. * * This method handles two scenarios automatically: - * 1. If no channel exists: Creates a new channel with the acknowledgement transition + * 1. If no channel exists, including received off-chain funds before channel creation: + * Creates a new channel with the acknowledgement transition * 2. If channel exists: Advances the state with an acknowledgement transition * * The returned state is signed by both the user and the node. @@ -663,12 +664,10 @@ export class Client { // No state exists } - // homeChannelId is intentionally not checked here: a non-null state with - // an undefined homeChannelId is valid — it represents a user who received - // funds but has not yet opened a channel. Only enter the creation path when - // there is truly no prior state at all. - // No channel path - create channel with acknowledgement - if (!state) { + // No open channel path - create channel with acknowledgement. A non-null + // state without a homeChannelId represents received off-chain funds before + // the user has opened a home channel. + if (!state || !state.homeChannelId) { // Get supported sig validators bitmap from node config const bitmap = await this.getSupportedSigValidatorsBitmap(); diff --git a/sdk/ts/test/unit/client.test.ts b/sdk/ts/test/unit/client.test.ts index 5cf453e70..464c649c2 100644 --- a/sdk/ts/test/unit/client.test.ts +++ b/sdk/ts/test/unit/client.test.ts @@ -1,6 +1,62 @@ import { Decimal } from 'decimal.js'; import { jest } from '@jest/globals'; import { Client } from '../../src/client.js'; +import * as core from '../../src/core/index.js'; + +const USER_WALLET = '0x1234567890123456789012345678901234567890' as const; +const NODE_ADDRESS = '0x1111111111111111111111111111111111111111' as const; +const TOKEN_ADDRESS = '0x2222222222222222222222222222222222222222' as const; +const HOME_CHANNEL_ID = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; +const USER_SIGNATURE = '0x00'; +const NODE_SIGNATURE = '0x01'; + +function createAcknowledgeClient(latestState?: core.State, latestStateError?: Error) { + const getLatestState = jest.fn(); + if (latestStateError) { + getLatestState.mockRejectedValue(latestStateError); + } else { + getLatestState.mockResolvedValue(latestState); + } + + const client = Object.create(Client.prototype) as any; + client.getUserAddress = jest.fn(() => USER_WALLET); + client.getLatestState = getLatestState; + client.getSupportedSigValidatorsBitmap = jest.fn().mockResolvedValue('0x00'); + client.homeBlockchains = new Map([['usdc', 11155111n]]); + client.assetStore = { + getSuggestedBlockchainId: jest.fn().mockResolvedValue(11155111n), + getTokenAddress: jest.fn().mockResolvedValue(TOKEN_ADDRESS), + }; + client.getNodeAddress = jest.fn().mockResolvedValue(NODE_ADDRESS); + client.signState = jest.fn().mockResolvedValue(USER_SIGNATURE); + client.requestChannelCreation = jest.fn().mockImplementation(async (state: core.State) => { + state.nodeSig = NODE_SIGNATURE; + return NODE_SIGNATURE; + }); + client.signAndSubmitState = jest.fn().mockImplementation(async (_current: core.State, proposed: core.State) => { + proposed.userSig = USER_SIGNATURE; + proposed.nodeSig = NODE_SIGNATURE; + return NODE_SIGNATURE; + }); + + return client as Client & Record; +} + +function receivedOffchainState(): core.State { + const state = core.newVoidState('usdc', USER_WALLET); + state.version = 3n; + state.homeLedger.userBalance = new Decimal(5); + state.homeLedger.userNetFlow = new Decimal(5); + state.homeLedger.nodeBalance = new Decimal(0); + state.homeLedger.nodeNetFlow = new Decimal(0); + return state; +} + +function openChannelState(): core.State { + const state = receivedOffchainState(); + state.homeChannelId = HOME_CHANNEL_ID; + return state; +} describe('Client.getOnChainBalance', () => { it('delegates to the initialized blockchain client for the requested chain', async () => { @@ -82,3 +138,58 @@ describe('Client.getOnChainBalance', () => { expect(getTokenBalance).toHaveBeenCalledWith('usdc', wallet); }); }); + +describe('Client.acknowledge', () => { + it('creates a channel with acknowledgement when latest state lookup fails', async () => { + const client = createAcknowledgeClient(undefined, new Error('state not found')); + + const state = await client.acknowledge('usdc'); + + expect(client.requestChannelCreation).toHaveBeenCalledTimes(1); + expect(client.signAndSubmitState).not.toHaveBeenCalled(); + expect(state.homeChannelId).toBeDefined(); + expect(state.transition.type).toBe(core.TransitionType.Acknowledgement); + expect(state.userSig).toBe(USER_SIGNATURE); + expect(state.nodeSig).toBe(NODE_SIGNATURE); + }); + + it('creates a channel with acknowledgement when received off-chain funds have no home channel', async () => { + const latestState = receivedOffchainState(); + const client = createAcknowledgeClient(latestState); + + const state = await client.acknowledge('usdc'); + + expect(client.requestChannelCreation).toHaveBeenCalledTimes(1); + expect(client.signAndSubmitState).not.toHaveBeenCalled(); + expect(state.version).toBe(latestState.version + 1n); + expect(state.homeChannelId).toBeDefined(); + expect(state.transition.type).toBe(core.TransitionType.Acknowledgement); + expect(state.userSig).toBe(USER_SIGNATURE); + expect(state.nodeSig).toBe(NODE_SIGNATURE); + }); + + it('submits an acknowledgement when latest state already has a home channel', async () => { + const latestState = openChannelState(); + const client = createAcknowledgeClient(latestState); + + const state = await client.acknowledge('usdc'); + + expect(client.requestChannelCreation).not.toHaveBeenCalled(); + expect(client.signAndSubmitState).toHaveBeenCalledTimes(1); + expect(state.homeChannelId).toBe(HOME_CHANNEL_ID); + expect(state.transition.type).toBe(core.TransitionType.Acknowledgement); + expect(state.userSig).toBe(USER_SIGNATURE); + expect(state.nodeSig).toBe(NODE_SIGNATURE); + }); + + it('rejects an already acknowledged state on an existing home channel', async () => { + const latestState = openChannelState(); + latestState.userSig = USER_SIGNATURE; + const client = createAcknowledgeClient(latestState); + + await expect(client.acknowledge('usdc')).rejects.toThrow('state already acknowledged by user'); + + expect(client.requestChannelCreation).not.toHaveBeenCalled(); + expect(client.signAndSubmitState).not.toHaveBeenCalled(); + }); +}); From 2052e6afd5d565c0fe41906b24e292f7e5ed1fa6 Mon Sep 17 00:00:00 2001 From: Maharshi Mishra Date: Mon, 11 May 2026 18:58:34 +0530 Subject: [PATCH 2/3] fix sdk offchain channel creation paths --- sdk/ts/src/client.ts | 20 ++++---- sdk/ts/test/unit/client.test.ts | 82 +++++++++++++++++++++++++++++---- 2 files changed, 83 insertions(+), 19 deletions(-) diff --git a/sdk/ts/src/client.ts b/sdk/ts/src/client.ts index 79797c0e7..7c42baa77 100644 --- a/sdk/ts/src/client.ts +++ b/sdk/ts/src/client.ts @@ -540,7 +540,8 @@ export class Client { /** * Transfer prepares a transfer state to send funds to another wallet address. * This method handles two scenarios automatically: - * 1. If no channel exists: Creates a new channel with the transfer transition + * 1. If no open channel exists, including received off-chain funds before channel creation: + * Creates a new channel with the transfer transition * 2. If channel exists: Advances the state with a transfer send transition * * The returned state is signed by both the user and the node. For existing channels, @@ -569,11 +570,10 @@ export class Client { // Channel doesn't exist } - // homeChannelId is intentionally not checked here: a non-null state with - // an undefined homeChannelId is valid — it represents a user who received - // funds but has not yet opened a channel. Only enter the creation path when - // there is truly no prior state at all. - if (!state) { + // No open channel path - create channel with transfer. A non-null state + // without a homeChannelId represents received off-chain funds before the + // user has opened a home channel. + if (!state || !state.homeChannelId) { // Get supported sig validators bitmap from node config const bitmap = await this.getSupportedSigValidatorsBitmap(); @@ -664,6 +664,10 @@ export class Client { // No state exists } + if (state?.userSig) { + throw new Error('state already acknowledged by user'); + } + // No open channel path - create channel with acknowledgement. A non-null // state without a homeChannelId represents received off-chain funds before // the user has opened a home channel. @@ -713,10 +717,6 @@ export class Client { return newState; } - if (state.userSig) { - throw new Error('state already acknowledged by user'); - } - // Has channel path - submit acknowledgement const newState = nextState(state); applyAcknowledgementTransition(newState); diff --git a/sdk/ts/test/unit/client.test.ts b/sdk/ts/test/unit/client.test.ts index 464c649c2..86d89fa29 100644 --- a/sdk/ts/test/unit/client.test.ts +++ b/sdk/ts/test/unit/client.test.ts @@ -4,13 +4,14 @@ import { Client } from '../../src/client.js'; import * as core from '../../src/core/index.js'; const USER_WALLET = '0x1234567890123456789012345678901234567890' as const; +const RECIPIENT_WALLET = '0x3333333333333333333333333333333333333333' as const; const NODE_ADDRESS = '0x1111111111111111111111111111111111111111' as const; const TOKEN_ADDRESS = '0x2222222222222222222222222222222222222222' as const; const HOME_CHANNEL_ID = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; const USER_SIGNATURE = '0x00'; const NODE_SIGNATURE = '0x01'; -function createAcknowledgeClient(latestState?: core.State, latestStateError?: Error) { +function createHighLevelClient(latestState?: core.State, latestStateError?: Error) { const getLatestState = jest.fn(); if (latestStateError) { getLatestState.mockRejectedValue(latestStateError); @@ -29,10 +30,7 @@ function createAcknowledgeClient(latestState?: core.State, latestStateError?: Er }; client.getNodeAddress = jest.fn().mockResolvedValue(NODE_ADDRESS); client.signState = jest.fn().mockResolvedValue(USER_SIGNATURE); - client.requestChannelCreation = jest.fn().mockImplementation(async (state: core.State) => { - state.nodeSig = NODE_SIGNATURE; - return NODE_SIGNATURE; - }); + client.requestChannelCreation = jest.fn().mockResolvedValue(NODE_SIGNATURE); client.signAndSubmitState = jest.fn().mockImplementation(async (_current: core.State, proposed: core.State) => { proposed.userSig = USER_SIGNATURE; proposed.nodeSig = NODE_SIGNATURE; @@ -141,7 +139,7 @@ describe('Client.getOnChainBalance', () => { describe('Client.acknowledge', () => { it('creates a channel with acknowledgement when latest state lookup fails', async () => { - const client = createAcknowledgeClient(undefined, new Error('state not found')); + const client = createHighLevelClient(undefined, new Error('state not found')); const state = await client.acknowledge('usdc'); @@ -155,7 +153,7 @@ describe('Client.acknowledge', () => { it('creates a channel with acknowledgement when received off-chain funds have no home channel', async () => { const latestState = receivedOffchainState(); - const client = createAcknowledgeClient(latestState); + const client = createHighLevelClient(latestState); const state = await client.acknowledge('usdc'); @@ -163,6 +161,8 @@ describe('Client.acknowledge', () => { expect(client.signAndSubmitState).not.toHaveBeenCalled(); expect(state.version).toBe(latestState.version + 1n); expect(state.homeChannelId).toBeDefined(); + expect(state.homeLedger.userBalance.equals(latestState.homeLedger.userBalance)).toBe(true); + expect(state.homeLedger.userNetFlow.equals(latestState.homeLedger.userNetFlow)).toBe(true); expect(state.transition.type).toBe(core.TransitionType.Acknowledgement); expect(state.userSig).toBe(USER_SIGNATURE); expect(state.nodeSig).toBe(NODE_SIGNATURE); @@ -170,7 +170,7 @@ describe('Client.acknowledge', () => { it('submits an acknowledgement when latest state already has a home channel', async () => { const latestState = openChannelState(); - const client = createAcknowledgeClient(latestState); + const client = createHighLevelClient(latestState); const state = await client.acknowledge('usdc'); @@ -185,7 +185,18 @@ describe('Client.acknowledge', () => { it('rejects an already acknowledged state on an existing home channel', async () => { const latestState = openChannelState(); latestState.userSig = USER_SIGNATURE; - const client = createAcknowledgeClient(latestState); + const client = createHighLevelClient(latestState); + + await expect(client.acknowledge('usdc')).rejects.toThrow('state already acknowledged by user'); + + expect(client.requestChannelCreation).not.toHaveBeenCalled(); + expect(client.signAndSubmitState).not.toHaveBeenCalled(); + }); + + it('rejects an already acknowledged state before creating a home channel', async () => { + const latestState = receivedOffchainState(); + latestState.userSig = USER_SIGNATURE; + const client = createHighLevelClient(latestState); await expect(client.acknowledge('usdc')).rejects.toThrow('state already acknowledged by user'); @@ -193,3 +204,56 @@ describe('Client.acknowledge', () => { expect(client.signAndSubmitState).not.toHaveBeenCalled(); }); }); + +describe('Client.transfer', () => { + it('creates a channel with transfer when latest state lookup fails', async () => { + const client = createHighLevelClient(undefined, new Error('state not found')); + const amount = new Decimal(1); + + const state = await client.transfer(RECIPIENT_WALLET, 'usdc', amount); + + expect(client.requestChannelCreation).toHaveBeenCalledTimes(1); + expect(client.signAndSubmitState).not.toHaveBeenCalled(); + expect(state.homeChannelId).toBeDefined(); + expect(state.transition.type).toBe(core.TransitionType.TransferSend); + expect(state.transition.accountId).toBe(RECIPIENT_WALLET); + expect(state.userSig).toBe(USER_SIGNATURE); + expect(state.nodeSig).toBe(NODE_SIGNATURE); + }); + + it('creates a channel with transfer when received off-chain funds have no home channel', async () => { + const latestState = receivedOffchainState(); + const client = createHighLevelClient(latestState); + const amount = new Decimal(2); + + const state = await client.transfer(RECIPIENT_WALLET, 'usdc', amount); + + expect(client.requestChannelCreation).toHaveBeenCalledTimes(1); + expect(client.signAndSubmitState).not.toHaveBeenCalled(); + expect(state.version).toBe(latestState.version + 1n); + expect(state.homeChannelId).toBeDefined(); + expect(state.homeLedger.userBalance.equals(latestState.homeLedger.userBalance.sub(amount))).toBe(true); + expect(state.homeLedger.userNetFlow.equals(latestState.homeLedger.userNetFlow)).toBe(true); + expect(state.transition.type).toBe(core.TransitionType.TransferSend); + expect(state.transition.accountId).toBe(RECIPIENT_WALLET); + expect(state.userSig).toBe(USER_SIGNATURE); + expect(state.nodeSig).toBe(NODE_SIGNATURE); + }); + + it('submits a transfer when latest state already has a home channel', async () => { + const latestState = openChannelState(); + const client = createHighLevelClient(latestState); + const amount = new Decimal(2); + + const state = await client.transfer(RECIPIENT_WALLET, 'usdc', amount); + + expect(client.requestChannelCreation).not.toHaveBeenCalled(); + expect(client.signAndSubmitState).toHaveBeenCalledTimes(1); + expect(state.homeChannelId).toBe(HOME_CHANNEL_ID); + expect(state.homeLedger.userBalance.equals(latestState.homeLedger.userBalance.sub(amount))).toBe(true); + expect(state.transition.type).toBe(core.TransitionType.TransferSend); + expect(state.transition.accountId).toBe(RECIPIENT_WALLET); + expect(state.userSig).toBe(USER_SIGNATURE); + expect(state.nodeSig).toBe(NODE_SIGNATURE); + }); +}); From 97d904fae93416ad30772e4f4cb236fa43d78109 Mon Sep 17 00:00:00 2001 From: Maharshi Mishra Date: Mon, 11 May 2026 19:13:44 +0530 Subject: [PATCH 3/3] fix sdk package version mirror --- sdk/mcp/package-lock.json | 10 +++++----- sdk/mcp/package.json | 2 +- sdk/mcp/server.json | 4 ++-- sdk/ts-compat/package-lock.json | 4 ++-- sdk/ts-compat/package.json | 2 +- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/sdk/mcp/package-lock.json b/sdk/mcp/package-lock.json index 9ce997c8a..5cd893191 100644 --- a/sdk/mcp/package-lock.json +++ b/sdk/mcp/package-lock.json @@ -1,12 +1,12 @@ { "name": "@yellow-org/sdk-mcp", - "version": "1.2.1", + "version": "1.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@yellow-org/sdk-mcp", - "version": "1.2.1", + "version": "1.2.2", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", @@ -938,9 +938,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "funding": [ { "type": "github", diff --git a/sdk/mcp/package.json b/sdk/mcp/package.json index a3868d2b9..0cd30c9ee 100644 --- a/sdk/mcp/package.json +++ b/sdk/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@yellow-org/sdk-mcp", - "version": "1.2.1", + "version": "1.2.2", "description": "Unified MCP server for Yellow SDK and Nitrolite protocol context for AI agents and IDEs", "type": "module", "mcpName": "io.github.layer-3/yellow-sdk-mcp", diff --git a/sdk/mcp/server.json b/sdk/mcp/server.json index fdfcbcd34..12e2b48f9 100644 --- a/sdk/mcp/server.json +++ b/sdk/mcp/server.json @@ -2,7 +2,7 @@ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", "name": "io.github.layer-3/yellow-sdk-mcp", "description": "MCP server exposing Yellow SDK and Nitrolite protocol reference material to AI agents and IDEs.", - "version": "1.2.1", + "version": "1.2.2", "repository": { "url": "https://github.com/layer-3/nitrolite", "source": "github" @@ -11,7 +11,7 @@ { "registryType": "npm", "identifier": "@yellow-org/sdk-mcp", - "version": "1.2.1", + "version": "1.2.2", "transport": { "type": "stdio" } diff --git a/sdk/ts-compat/package-lock.json b/sdk/ts-compat/package-lock.json index 6367fde30..eacbdae12 100644 --- a/sdk/ts-compat/package-lock.json +++ b/sdk/ts-compat/package-lock.json @@ -1,12 +1,12 @@ { "name": "@yellow-org/sdk-compat", - "version": "1.2.1", + "version": "1.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@yellow-org/sdk-compat", - "version": "1.2.1", + "version": "1.2.2", "license": "MIT", "dependencies": { "decimal.js": "^10.4.3" diff --git a/sdk/ts-compat/package.json b/sdk/ts-compat/package.json index 78d687d40..b484f30c1 100644 --- a/sdk/ts-compat/package.json +++ b/sdk/ts-compat/package.json @@ -1,6 +1,6 @@ { "name": "@yellow-org/sdk-compat", - "version": "1.2.1", + "version": "1.2.2", "description": "Curated migration layer preserving selected Nitrolite SDK v0.5.3 app-facing APIs over the v1 runtime.", "type": "module", "sideEffects": false,