Skip to content
Merged
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
10 changes: 5 additions & 5 deletions sdk/mcp/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion sdk/mcp/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
4 changes: 2 additions & 2 deletions sdk/mcp/server.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -11,7 +11,7 @@
{
"registryType": "npm",
"identifier": "@yellow-org/sdk-mcp",
"version": "1.2.1",
"version": "1.2.2",
"transport": {
"type": "stdio"
}
Expand Down
4 changes: 2 additions & 2 deletions sdk/ts-compat/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion sdk/ts-compat/package.json
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
4 changes: 2 additions & 2 deletions sdk/ts/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion sdk/ts/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
33 changes: 16 additions & 17 deletions sdk/ts/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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.
Expand All @@ -663,12 +664,14 @@ 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) {
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.
if (!state || !state.homeChannelId) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Get supported sig validators bitmap from node config
const bitmap = await this.getSupportedSigValidatorsBitmap();

Expand Down Expand Up @@ -714,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);
Expand Down
175 changes: 175 additions & 0 deletions sdk/ts/test/unit/client.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,60 @@
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 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 createHighLevelClient(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().mockResolvedValue(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<string, any>;
}

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 () => {
Expand Down Expand Up @@ -82,3 +136,124 @@ 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 = createHighLevelClient(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 = createHighLevelClient(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.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);
});
Comment thread
nksazonov marked this conversation as resolved.

it('submits an acknowledgement when latest state already has a home channel', async () => {
const latestState = openChannelState();
const client = createHighLevelClient(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 = 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');

expect(client.requestChannelCreation).not.toHaveBeenCalled();
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);
});
});
Loading