mirror of
https://github.com/fluencelabs/fluence-js.git
synced 2024-12-04 09:50:17 +00:00
feat(js-client)!: Particle signatures [fixes DXJ-466] (#353)
* Introduce particle signatures * Fix particle id field in particle context * Fix types * Fix review comments * Remove init_peer_id from signature * Fix typo * Fix error msg * Fix async promise constructor antipattern * Refactor utils * Move text encoder outside * Use async/await * Update packages/core/js-client/src/connection/RelayConnection.ts Co-authored-by: shamsartem <shamsartem@gmail.com> * Hide crypto implementation beside KeyPair * Fix verify method * Comment verify method * Use particle signature instead of id * remove async/await from method * Fix type * Update packages/core/js-client/src/particle/interfaces.ts Co-authored-by: folex <0xdxdy@gmail.com> * Update packages/core/js-client/src/particle/Particle.ts Co-authored-by: folex <0xdxdy@gmail.com> * Fix review comment * Update pipe * set logging * try cache --------- Co-authored-by: shamsartem <shamsartem@gmail.com> Co-authored-by: folex <0xdxdy@gmail.com>
This commit is contained in:
parent
15a2c91917
commit
c0b73fec4a
2
.github/workflows/tests.yml
vendored
2
.github/workflows/tests.yml
vendored
@ -97,8 +97,6 @@ jobs:
|
|||||||
"@fluencelabs/marine-js": "${{ inputs.marine-js-version }}"
|
"@fluencelabs/marine-js": "${{ inputs.marine-js-version }}"
|
||||||
}
|
}
|
||||||
|
|
||||||
- uses: browser-actions/setup-chrome@v1
|
|
||||||
|
|
||||||
- run: pnpm -r --no-frozen-lockfile i
|
- run: pnpm -r --no-frozen-lockfile i
|
||||||
- run: pnpm -r build
|
- run: pnpm -r build
|
||||||
- run: pnpm -r test
|
- run: pnpm -r test
|
||||||
|
3
.npmrc
3
.npmrc
@ -1,2 +1,3 @@
|
|||||||
auto-install-peers=true
|
auto-install-peers=true
|
||||||
save-exact=true
|
save-exact=true
|
||||||
|
side-effects-cache=false
|
@ -11,8 +11,7 @@ describe('FluenceClient usage test suite', () => {
|
|||||||
await withClient(RELAY, {}, async (peer) => {
|
await withClient(RELAY, {}, async (peer) => {
|
||||||
// arrange
|
// arrange
|
||||||
|
|
||||||
const result = await new Promise<string[]>((resolve, reject) => {
|
const script = `
|
||||||
const script = `
|
|
||||||
(xor
|
(xor
|
||||||
(seq
|
(seq
|
||||||
(call %init_peer_id% ("load" "relay") [] init_relay)
|
(call %init_peer_id% ("load" "relay") [] init_relay)
|
||||||
@ -26,8 +25,10 @@ describe('FluenceClient usage test suite', () => {
|
|||||||
(call %init_peer_id% ("callback" "error") [%last_error%])
|
(call %init_peer_id% ("callback" "error") [%last_error%])
|
||||||
)
|
)
|
||||||
)`;
|
)`;
|
||||||
const particle = peer.internals.createNewParticle(script);
|
|
||||||
|
|
||||||
|
const particle = await peer.internals.createNewParticle(script);
|
||||||
|
|
||||||
|
const result = await new Promise<string>((resolve, reject) => {
|
||||||
if (particle instanceof Error) {
|
if (particle instanceof Error) {
|
||||||
return reject(particle.message);
|
return reject(particle.message);
|
||||||
}
|
}
|
||||||
@ -92,7 +93,7 @@ describe('FluenceClient usage test suite', () => {
|
|||||||
(call "${peer2.getPeerId()}" ("test" "test") ["test"])
|
(call "${peer2.getPeerId()}" ("test" "test") ["test"])
|
||||||
)
|
)
|
||||||
`;
|
`;
|
||||||
const particle = peer1.internals.createNewParticle(script);
|
const particle = await peer1.internals.createNewParticle(script);
|
||||||
|
|
||||||
if (particle instanceof Error) {
|
if (particle instanceof Error) {
|
||||||
throw particle;
|
throw particle;
|
||||||
@ -149,14 +150,13 @@ describe('FluenceClient usage test suite', () => {
|
|||||||
|
|
||||||
it.skip('Should throw correct error when the client tries to send a particle not to the relay', async () => {
|
it.skip('Should throw correct error when the client tries to send a particle not to the relay', async () => {
|
||||||
await withClient(RELAY, {}, async (peer) => {
|
await withClient(RELAY, {}, async (peer) => {
|
||||||
const promise = new Promise((resolve, reject) => {
|
const script = `
|
||||||
const script = `
|
|
||||||
(xor
|
(xor
|
||||||
(call "incorrect_peer_id" ("any" "service") [])
|
(call "incorrect_peer_id" ("any" "service") [])
|
||||||
(call %init_peer_id% ("callback" "error") [%last_error%])
|
(call %init_peer_id% ("callback" "error") [%last_error%])
|
||||||
)`;
|
)`;
|
||||||
const particle = peer.internals.createNewParticle(script);
|
const particle = await peer.internals.createNewParticle(script);
|
||||||
|
const promise = new Promise((resolve, reject) => {
|
||||||
if (particle instanceof Error) {
|
if (particle instanceof Error) {
|
||||||
return reject(particle.message);
|
return reject(particle.message);
|
||||||
}
|
}
|
||||||
|
@ -28,8 +28,7 @@ const log = logger('connection');
|
|||||||
export const checkConnection = async (peer: ClientPeer, ttl?: number): Promise<boolean> => {
|
export const checkConnection = async (peer: ClientPeer, ttl?: number): Promise<boolean> => {
|
||||||
const msg = Math.random().toString(36).substring(7);
|
const msg = Math.random().toString(36).substring(7);
|
||||||
|
|
||||||
const promise = new Promise<string>((resolve, reject) => {
|
const script = `
|
||||||
const script = `
|
|
||||||
(xor
|
(xor
|
||||||
(seq
|
(seq
|
||||||
(call %init_peer_id% ("load" "relay") [] init_relay)
|
(call %init_peer_id% ("load" "relay") [] init_relay)
|
||||||
@ -46,8 +45,9 @@ export const checkConnection = async (peer: ClientPeer, ttl?: number): Promise<b
|
|||||||
(call %init_peer_id% ("callback" "error") [%last_error%])
|
(call %init_peer_id% ("callback" "error") [%last_error%])
|
||||||
)
|
)
|
||||||
)`;
|
)`;
|
||||||
const particle = peer.internals.createNewParticle(script, ttl);
|
const particle = await peer.internals.createNewParticle(script, ttl);
|
||||||
|
|
||||||
|
const promise = new Promise<string>((resolve, reject) => {
|
||||||
if (particle instanceof Error) {
|
if (particle instanceof Error) {
|
||||||
return reject(particle.message);
|
return reject(particle.message);
|
||||||
}
|
}
|
||||||
|
@ -13,19 +13,20 @@
|
|||||||
* See the License for the specific language governing permissions and
|
* See the License for the specific language governing permissions and
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
import { getArgumentTypes, isReturnTypeVoid, CallAquaFunctionType } from '@fluencelabs/interfaces';
|
import { CallAquaFunctionType, getArgumentTypes, isReturnTypeVoid } from '@fluencelabs/interfaces';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
errorHandlingService,
|
||||||
injectRelayService,
|
injectRelayService,
|
||||||
|
injectValueService,
|
||||||
registerParticleScopeService,
|
registerParticleScopeService,
|
||||||
responseService,
|
responseService,
|
||||||
errorHandlingService,
|
|
||||||
ServiceDescription,
|
ServiceDescription,
|
||||||
userHandlerService,
|
userHandlerService,
|
||||||
injectValueService,
|
|
||||||
} from './services.js';
|
} from './services.js';
|
||||||
|
|
||||||
import { logger } from '../util/logger.js';
|
import { logger } from '../util/logger.js';
|
||||||
|
import { IParticle } from '../particle/interfaces.js';
|
||||||
|
|
||||||
const log = logger('aqua');
|
const log = logger('aqua');
|
||||||
|
|
||||||
@ -40,13 +41,13 @@ const log = logger('aqua');
|
|||||||
* @param args - args in the form of JSON where each key corresponds to the name of the argument
|
* @param args - args in the form of JSON where each key corresponds to the name of the argument
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
export const callAquaFunction: CallAquaFunctionType = ({ def, script, config, peer, args }) => {
|
export const callAquaFunction: CallAquaFunctionType = async ({ def, script, config, peer, args }) => {
|
||||||
log.trace('calling aqua function %j', { def, script, config, args });
|
log.trace('calling aqua function %j', { def, script, config, args });
|
||||||
const argumentTypes = getArgumentTypes(def);
|
const argumentTypes = getArgumentTypes(def);
|
||||||
|
|
||||||
const promise = new Promise((resolve, reject) => {
|
const particle = await peer.internals.createNewParticle(script, config?.ttl);
|
||||||
const particle = peer.internals.createNewParticle(script, config?.ttl);
|
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
if (particle instanceof Error) {
|
if (particle instanceof Error) {
|
||||||
return reject(particle.message);
|
return reject(particle.message);
|
||||||
}
|
}
|
||||||
@ -92,7 +93,5 @@ export const callAquaFunction: CallAquaFunctionType = ({ def, script, config, pe
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
})
|
||||||
|
|
||||||
return promise;
|
|
||||||
};
|
};
|
||||||
|
@ -28,6 +28,7 @@ import {
|
|||||||
IFluenceInternalApi,
|
IFluenceInternalApi,
|
||||||
} from '@fluencelabs/interfaces';
|
} from '@fluencelabs/interfaces';
|
||||||
import { CallServiceData, GenericCallServiceHandler, ResultCodes } from '../jsServiceHost/interfaces.js';
|
import { CallServiceData, GenericCallServiceHandler, ResultCodes } from '../jsServiceHost/interfaces.js';
|
||||||
|
import { fromUint8Array } from 'js-base64';
|
||||||
|
|
||||||
export interface ServiceDescription {
|
export interface ServiceDescription {
|
||||||
serviceId: string;
|
serviceId: string;
|
||||||
@ -177,6 +178,7 @@ const extractCallParams = (req: CallServiceData, arrow: ArrowWithoutCallbacks):
|
|||||||
|
|
||||||
const callParams = {
|
const callParams = {
|
||||||
...req.particleContext,
|
...req.particleContext,
|
||||||
|
signature: fromUint8Array(req.particleContext.signature),
|
||||||
tetraplets,
|
tetraplets,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -15,7 +15,7 @@
|
|||||||
*/
|
*/
|
||||||
import { PeerIdB58 } from '@fluencelabs/interfaces';
|
import { PeerIdB58 } from '@fluencelabs/interfaces';
|
||||||
import { pipe } from 'it-pipe';
|
import { pipe } from 'it-pipe';
|
||||||
import { encode, decode } from 'it-length-prefixed';
|
import { decode, encode } from 'it-length-prefixed';
|
||||||
import type { PeerId } from '@libp2p/interface/peer-id';
|
import type { PeerId } from '@libp2p/interface/peer-id';
|
||||||
import { createLibp2p, Libp2p } from 'libp2p';
|
import { createLibp2p, Libp2p } from 'libp2p';
|
||||||
|
|
||||||
@ -23,8 +23,7 @@ import { noise } from '@chainsafe/libp2p-noise';
|
|||||||
import { yamux } from '@chainsafe/libp2p-yamux';
|
import { yamux } from '@chainsafe/libp2p-yamux';
|
||||||
import { webSockets } from '@libp2p/websockets';
|
import { webSockets } from '@libp2p/websockets';
|
||||||
import { all } from '@libp2p/websockets/filters';
|
import { all } from '@libp2p/websockets/filters';
|
||||||
import { multiaddr } from '@multiformats/multiaddr';
|
import { multiaddr, type Multiaddr } from '@multiformats/multiaddr';
|
||||||
import type { Multiaddr } from '@multiformats/multiaddr';
|
|
||||||
|
|
||||||
import map from 'it-map';
|
import map from 'it-map';
|
||||||
import { fromString } from 'uint8arrays/from-string';
|
import { fromString } from 'uint8arrays/from-string';
|
||||||
@ -35,9 +34,13 @@ import { Subject } from 'rxjs';
|
|||||||
import { throwIfHasNoPeerId } from '../util/libp2pUtils.js';
|
import { throwIfHasNoPeerId } from '../util/libp2pUtils.js';
|
||||||
import { IConnection } from './interfaces.js';
|
import { IConnection } from './interfaces.js';
|
||||||
import { IParticle } from '../particle/interfaces.js';
|
import { IParticle } from '../particle/interfaces.js';
|
||||||
import { Particle, serializeToString } from '../particle/Particle.js';
|
import { Particle, serializeToString, verifySignature } from '../particle/Particle.js';
|
||||||
import { identifyService } from 'libp2p/identify';
|
import { identifyService } from 'libp2p/identify';
|
||||||
import { pingService } from 'libp2p/ping';
|
import { pingService } from 'libp2p/ping';
|
||||||
|
import { unmarshalPublicKey } from '@libp2p/crypto/keys';
|
||||||
|
import { peerIdFromString } from '@libp2p/peer-id';
|
||||||
|
import { Stream } from '@libp2p/interface/connection';
|
||||||
|
import { KeyPair } from '../keypair/index.js';
|
||||||
|
|
||||||
const log = logger('connection');
|
const log = logger('connection');
|
||||||
|
|
||||||
@ -170,6 +173,31 @@ export class RelayConnection implements IConnection {
|
|||||||
);
|
);
|
||||||
log.trace('data written to sink');
|
log.trace('data written to sink');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async processIncomingMessage(msg: string, stream: Stream) {
|
||||||
|
let particle: Particle | undefined;
|
||||||
|
try {
|
||||||
|
particle = Particle.fromString(msg);
|
||||||
|
log.trace('got particle from stream with id %s and particle id %s', stream.id, particle.id);
|
||||||
|
const initPeerId = peerIdFromString(particle.initPeerId);
|
||||||
|
|
||||||
|
if (initPeerId.publicKey === undefined) {
|
||||||
|
log.error('cannot retrieve public key from init_peer_id. particle id: %s. init_peer_id: %s', particle.id, particle.initPeerId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isVerified = await verifySignature(particle, initPeerId.publicKey);
|
||||||
|
if (isVerified) {
|
||||||
|
this.particleSource.next(particle);
|
||||||
|
} else {
|
||||||
|
log.trace('particle signature is incorrect. rejecting particle with id: %s', particle.id);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const particleId = particle?.id;
|
||||||
|
const particleIdMessage = typeof particleId === 'string' ? `. particle id: ${particleId}` : '';
|
||||||
|
log.error(`error on handling an incoming message: %O%s`, e, particleIdMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async connect() {
|
private async connect() {
|
||||||
if (this.lib2p2Peer === null) {
|
if (this.lib2p2Peer === null) {
|
||||||
@ -178,30 +206,20 @@ export class RelayConnection implements IConnection {
|
|||||||
|
|
||||||
await this.lib2p2Peer.handle(
|
await this.lib2p2Peer.handle(
|
||||||
[PROTOCOL_NAME],
|
[PROTOCOL_NAME],
|
||||||
async ({ connection, stream }) => {
|
async ({ connection, stream }) => pipe(
|
||||||
pipe(
|
stream.source,
|
||||||
stream.source,
|
decode(),
|
||||||
// @ts-ignore
|
(source) => map(source, (buf) => toString(buf.subarray())),
|
||||||
decode(),
|
async (source) => {
|
||||||
// @ts-ignore
|
try {
|
||||||
(source) => map(source, (buf) => toString(buf.subarray())),
|
for await (const msg of source) {
|
||||||
async (source) => {
|
await this.processIncomingMessage(msg, stream);
|
||||||
try {
|
|
||||||
for await (const msg of source) {
|
|
||||||
try {
|
|
||||||
const particle = Particle.fromString(msg);
|
|
||||||
log.trace('got particle from stream with id %s and particle id %s', stream.id, particle.id);
|
|
||||||
this.particleSource.next(particle);
|
|
||||||
} catch (e) {
|
|
||||||
log.error('error on handling a new incoming message: %j', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
log.error('connection closed: %j', e);
|
|
||||||
}
|
}
|
||||||
},
|
} catch (e) {
|
||||||
);
|
log.error('connection closed: %j', e);
|
||||||
},
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
{
|
{
|
||||||
maxInboundStreams: this.config.maxInboundStreams,
|
maxInboundStreams: this.config.maxInboundStreams,
|
||||||
maxOutboundStreams: this.config.maxOutboundStreams,
|
maxOutboundStreams: this.config.maxOutboundStreams,
|
||||||
|
@ -59,7 +59,7 @@ describe.skip('Ephemeral networks tests', () => {
|
|||||||
)
|
)
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const particle = client.internals.createNewParticle(script);
|
const particle = await client.internals.createNewParticle(script);
|
||||||
|
|
||||||
const promise = new Promise<string>((resolve) => {
|
const promise = new Promise<string>((resolve) => {
|
||||||
client.internals.regHandler.forParticle(particle.id, 'test', 'test', (req: CallServiceData) => {
|
client.internals.regHandler.forParticle(particle.id, 'test', 'test', (req: CallServiceData) => {
|
||||||
|
@ -64,6 +64,7 @@ import {
|
|||||||
ResultCodes,
|
ResultCodes,
|
||||||
} from '../jsServiceHost/interfaces.js';
|
} from '../jsServiceHost/interfaces.js';
|
||||||
import { JSONValue } from '../util/commonTypes.js';
|
import { JSONValue } from '../util/commonTypes.js';
|
||||||
|
import { fromUint8Array } from 'js-base64';
|
||||||
|
|
||||||
const log_particle = logger('particle');
|
const log_particle = logger('particle');
|
||||||
const log_peer = logger('peer');
|
const log_peer = logger('peer');
|
||||||
@ -217,8 +218,8 @@ export abstract class FluencePeer {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
createNewParticle: (script: string, ttl: number = this.config.defaultTtlMs): IParticle => {
|
createNewParticle: (script: string, ttl: number = this.config.defaultTtlMs): Promise<IParticle> => {
|
||||||
return Particle.createNew(script, this.keyPair.getPeerId(), ttl);
|
return Particle.createNew(script, this.keyPair.getPeerId(), ttl, this.keyPair);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -317,7 +318,7 @@ export abstract class FluencePeer {
|
|||||||
log_particle.trace('id %s. call results: %j', item.particle.id, item.callResults);
|
log_particle.trace('id %s. call results: %j', item.particle.id, item.callResults);
|
||||||
}),
|
}),
|
||||||
filterExpiredParticles(this._expireParticle.bind(this)),
|
filterExpiredParticles(this._expireParticle.bind(this)),
|
||||||
groupBy(item => item.particle.id),
|
groupBy(item => fromUint8Array(item.particle.signature)),
|
||||||
mergeMap(group$ => {
|
mergeMap(group$ => {
|
||||||
let prevData: Uint8Array = Buffer.from([]);
|
let prevData: Uint8Array = Buffer.from([]);
|
||||||
let firstRun = true;
|
let firstRun = true;
|
||||||
|
@ -5,19 +5,19 @@ import { handleTimeout } from '../../particle/Particle.js';
|
|||||||
describe('Basic AVM functionality in Fluence Peer tests', () => {
|
describe('Basic AVM functionality in Fluence Peer tests', () => {
|
||||||
it('Simple call', async () => {
|
it('Simple call', async () => {
|
||||||
await withPeer(async (peer) => {
|
await withPeer(async (peer) => {
|
||||||
const res = await new Promise<string[]>((resolve, reject) => {
|
const script = `
|
||||||
const script = `
|
|
||||||
(call %init_peer_id% ("print" "print") ["1"])
|
(call %init_peer_id% ("print" "print") ["1"])
|
||||||
`;
|
`;
|
||||||
const particle = peer.internals.createNewParticle(script);
|
const particle = await peer.internals.createNewParticle(script);
|
||||||
|
|
||||||
|
const res = await new Promise<string>((resolve, reject) => {
|
||||||
if (particle instanceof Error) {
|
if (particle instanceof Error) {
|
||||||
return reject(particle.message);
|
return reject(particle.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
registerHandlersHelper(peer, particle, {
|
registerHandlersHelper(peer, particle, {
|
||||||
print: {
|
print: {
|
||||||
print: (args: Array<Array<string>>) => {
|
print: (args: Array<string>) => {
|
||||||
const [res] = args;
|
const [res] = args;
|
||||||
resolve(res);
|
resolve(res);
|
||||||
},
|
},
|
||||||
@ -33,9 +33,7 @@ describe('Basic AVM functionality in Fluence Peer tests', () => {
|
|||||||
|
|
||||||
it('Par call', async () => {
|
it('Par call', async () => {
|
||||||
await withPeer(async (peer) => {
|
await withPeer(async (peer) => {
|
||||||
const res = await new Promise<string[]>((resolve, reject) => {
|
const script = `
|
||||||
const res: any[] = [];
|
|
||||||
const script = `
|
|
||||||
(seq
|
(seq
|
||||||
(par
|
(par
|
||||||
(call %init_peer_id% ("print" "print") ["1"])
|
(call %init_peer_id% ("print" "print") ["1"])
|
||||||
@ -44,7 +42,10 @@ describe('Basic AVM functionality in Fluence Peer tests', () => {
|
|||||||
(call %init_peer_id% ("print" "print") ["2"])
|
(call %init_peer_id% ("print" "print") ["2"])
|
||||||
)
|
)
|
||||||
`;
|
`;
|
||||||
const particle = peer.internals.createNewParticle(script);
|
const particle = await peer.internals.createNewParticle(script);
|
||||||
|
|
||||||
|
const res = await new Promise<string[]>((resolve, reject) => {
|
||||||
|
const res: any[] = [];
|
||||||
|
|
||||||
if (particle instanceof Error) {
|
if (particle instanceof Error) {
|
||||||
return reject(particle.message);
|
return reject(particle.message);
|
||||||
@ -70,8 +71,7 @@ describe('Basic AVM functionality in Fluence Peer tests', () => {
|
|||||||
|
|
||||||
it('Timeout in par call: race', async () => {
|
it('Timeout in par call: race', async () => {
|
||||||
await withPeer(async (peer) => {
|
await withPeer(async (peer) => {
|
||||||
const res = await new Promise((resolve, reject) => {
|
const script = `
|
||||||
const script = `
|
|
||||||
(seq
|
(seq
|
||||||
(call %init_peer_id% ("op" "identity") ["slow_result"] arg)
|
(call %init_peer_id% ("op" "identity") ["slow_result"] arg)
|
||||||
(seq
|
(seq
|
||||||
@ -86,8 +86,9 @@ describe('Basic AVM functionality in Fluence Peer tests', () => {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
`;
|
`;
|
||||||
const particle = peer.internals.createNewParticle(script);
|
const particle = await peer.internals.createNewParticle(script);
|
||||||
|
|
||||||
|
const res = await new Promise((resolve, reject) => {
|
||||||
if (particle instanceof Error) {
|
if (particle instanceof Error) {
|
||||||
return reject(particle.message);
|
return reject(particle.message);
|
||||||
}
|
}
|
||||||
@ -109,8 +110,7 @@ describe('Basic AVM functionality in Fluence Peer tests', () => {
|
|||||||
|
|
||||||
it('Timeout in par call: wait', async () => {
|
it('Timeout in par call: wait', async () => {
|
||||||
await withPeer(async (peer) => {
|
await withPeer(async (peer) => {
|
||||||
const res = await new Promise((resolve, reject) => {
|
const script = `
|
||||||
const script = `
|
|
||||||
(seq
|
(seq
|
||||||
(call %init_peer_id% ("op" "identity") ["timeout_msg"] arg)
|
(call %init_peer_id% ("op" "identity") ["timeout_msg"] arg)
|
||||||
(seq
|
(seq
|
||||||
@ -136,8 +136,9 @@ describe('Basic AVM functionality in Fluence Peer tests', () => {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
`;
|
`;
|
||||||
const particle = peer.internals.createNewParticle(script);
|
const particle = await peer.internals.createNewParticle(script);
|
||||||
|
|
||||||
|
const res = await new Promise((resolve, reject) => {
|
||||||
if (particle instanceof Error) {
|
if (particle instanceof Error) {
|
||||||
return reject(particle.message);
|
return reject(particle.message);
|
||||||
}
|
}
|
||||||
|
@ -21,8 +21,7 @@ import { CallServiceData, ResultCodes } from '../../jsServiceHost/interfaces.js'
|
|||||||
describe('FluencePeer flow tests', () => {
|
describe('FluencePeer flow tests', () => {
|
||||||
it('should execute par instruction in parallel', async function () {
|
it('should execute par instruction in parallel', async function () {
|
||||||
await withPeer(async (peer) => {
|
await withPeer(async (peer) => {
|
||||||
const res = await new Promise<any>((resolve, reject) => {
|
const script = `
|
||||||
const script = `
|
|
||||||
(par
|
(par
|
||||||
(seq
|
(seq
|
||||||
(call %init_peer_id% ("flow" "timeout") [1000 "test1"] res1)
|
(call %init_peer_id% ("flow" "timeout") [1000 "test1"] res1)
|
||||||
@ -35,8 +34,9 @@ describe('FluencePeer flow tests', () => {
|
|||||||
)
|
)
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const particle = peer.internals.createNewParticle(script);
|
const particle = await peer.internals.createNewParticle(script);
|
||||||
|
|
||||||
|
const res = await new Promise<any>((resolve, reject) => {
|
||||||
peer.internals.regHandler.forParticle(particle.id, 'flow', 'timeout', (req: CallServiceData) => {
|
peer.internals.regHandler.forParticle(particle.id, 'flow', 'timeout', (req: CallServiceData) => {
|
||||||
const [timeout, message] = req.args;
|
const [timeout, message] = req.args;
|
||||||
|
|
||||||
|
@ -28,15 +28,15 @@ describe('FluencePeer usage test suite', () => {
|
|||||||
|
|
||||||
it('Should successfully call identity on local peer', async function () {
|
it('Should successfully call identity on local peer', async function () {
|
||||||
await withPeer(async (peer) => {
|
await withPeer(async (peer) => {
|
||||||
const res = await new Promise<string>((resolve, reject) => {
|
const script = `
|
||||||
const script = `
|
|
||||||
(seq
|
(seq
|
||||||
(call %init_peer_id% ("op" "identity") ["test"] res)
|
(call %init_peer_id% ("op" "identity") ["test"] res)
|
||||||
(call %init_peer_id% ("callback" "callback") [res])
|
(call %init_peer_id% ("callback" "callback") [res])
|
||||||
)
|
)
|
||||||
`;
|
`;
|
||||||
const particle = peer.internals.createNewParticle(script);
|
const particle = await peer.internals.createNewParticle(script);
|
||||||
|
|
||||||
|
const res = await new Promise<string>((resolve, reject) => {
|
||||||
if (particle instanceof Error) {
|
if (particle instanceof Error) {
|
||||||
return reject(particle.message);
|
return reject(particle.message);
|
||||||
}
|
}
|
||||||
@ -72,8 +72,7 @@ describe('FluencePeer usage test suite', () => {
|
|||||||
|
|
||||||
it('Should not crash if undefined is passed as a variable', async () => {
|
it('Should not crash if undefined is passed as a variable', async () => {
|
||||||
await withPeer(async (peer) => {
|
await withPeer(async (peer) => {
|
||||||
const res = await new Promise<any>((resolve, reject) => {
|
const script = `
|
||||||
const script = `
|
|
||||||
(seq
|
(seq
|
||||||
(call %init_peer_id% ("load" "arg") [] arg)
|
(call %init_peer_id% ("load" "arg") [] arg)
|
||||||
(seq
|
(seq
|
||||||
@ -81,8 +80,9 @@ describe('FluencePeer usage test suite', () => {
|
|||||||
(call %init_peer_id% ("callback" "callback") [res])
|
(call %init_peer_id% ("callback" "callback") [res])
|
||||||
)
|
)
|
||||||
)`;
|
)`;
|
||||||
const particle = peer.internals.createNewParticle(script);
|
const particle = await peer.internals.createNewParticle(script);
|
||||||
|
|
||||||
|
const res = await new Promise<any>((resolve, reject) => {
|
||||||
if (particle instanceof Error) {
|
if (particle instanceof Error) {
|
||||||
return reject(particle.message);
|
return reject(particle.message);
|
||||||
}
|
}
|
||||||
@ -112,14 +112,14 @@ describe('FluencePeer usage test suite', () => {
|
|||||||
|
|
||||||
it('Should not crash if an error ocurred in user-defined handler', async () => {
|
it('Should not crash if an error ocurred in user-defined handler', async () => {
|
||||||
await withPeer(async (peer) => {
|
await withPeer(async (peer) => {
|
||||||
const promise = new Promise<any>((_resolve, reject) => {
|
const script = `
|
||||||
const script = `
|
|
||||||
(xor
|
(xor
|
||||||
(call %init_peer_id% ("load" "arg") [] arg)
|
(call %init_peer_id% ("load" "arg") [] arg)
|
||||||
(call %init_peer_id% ("callback" "error") [%last_error%])
|
(call %init_peer_id% ("callback" "error") [%last_error%])
|
||||||
)`;
|
)`;
|
||||||
const particle = peer.internals.createNewParticle(script);
|
const particle = await peer.internals.createNewParticle(script);
|
||||||
|
|
||||||
|
const promise = new Promise<any>((_resolve, reject) => {
|
||||||
if (particle instanceof Error) {
|
if (particle instanceof Error) {
|
||||||
return reject(particle.message);
|
return reject(particle.message);
|
||||||
}
|
}
|
||||||
@ -149,14 +149,14 @@ describe('FluencePeer usage test suite', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function callIncorrectService(peer: FluencePeer): Promise<string[]> {
|
async function callIncorrectService(peer: FluencePeer): Promise<string[]> {
|
||||||
return new Promise<any[]>((resolve, reject) => {
|
const script = `
|
||||||
const script = `
|
|
||||||
(xor
|
(xor
|
||||||
(call %init_peer_id% ("incorrect" "incorrect") [] res)
|
(call %init_peer_id% ("incorrect" "incorrect") [] res)
|
||||||
(call %init_peer_id% ("callback" "error") [%last_error%])
|
(call %init_peer_id% ("callback" "error") [%last_error%])
|
||||||
)`;
|
)`;
|
||||||
const particle = peer.internals.createNewParticle(script);
|
const particle = await peer.internals.createNewParticle(script);
|
||||||
|
|
||||||
|
return new Promise<any[]>((resolve, reject) => {
|
||||||
if (particle instanceof Error) {
|
if (particle instanceof Error) {
|
||||||
return reject(particle.message);
|
return reject(particle.message);
|
||||||
}
|
}
|
||||||
|
@ -94,7 +94,7 @@ export interface ParticleContext {
|
|||||||
/**
|
/**
|
||||||
* Particle's signature
|
* Particle's signature
|
||||||
*/
|
*/
|
||||||
signature?: string;
|
signature: Uint8Array;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -1,3 +1,18 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2023 Fluence Labs Limited
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
import { it, describe, expect } from 'vitest';
|
import { it, describe, expect } from 'vitest';
|
||||||
import { toUint8Array } from 'js-base64';
|
import { toUint8Array } from 'js-base64';
|
||||||
import * as bs58 from 'bs58';
|
import * as bs58 from 'bs58';
|
||||||
|
@ -15,9 +15,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { PeerId } from '@libp2p/interface/peer-id';
|
import type { PeerId } from '@libp2p/interface/peer-id';
|
||||||
import { generateKeyPairFromSeed, generateKeyPair } from '@libp2p/crypto/keys';
|
import { generateKeyPairFromSeed, generateKeyPair, unmarshalPublicKey } from '@libp2p/crypto/keys';
|
||||||
import { createFromPrivKey } from '@libp2p/peer-id-factory';
|
import { createFromPrivKey, createFromPubKey } from '@libp2p/peer-id-factory';
|
||||||
import type { PrivateKey } from '@libp2p/interface/keys';
|
import type { PrivateKey, PublicKey } from '@libp2p/interface/keys';
|
||||||
import { toUint8Array } from 'js-base64';
|
import { toUint8Array } from 'js-base64';
|
||||||
import * as bs58 from 'bs58';
|
import * as bs58 from 'bs58';
|
||||||
import { KeyPairOptions } from '@fluencelabs/interfaces';
|
import { KeyPairOptions } from '@fluencelabs/interfaces';
|
||||||
@ -33,7 +33,11 @@ export class KeyPair {
|
|||||||
return this.libp2pPeerId;
|
return this.libp2pPeerId;
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(private key: PrivateKey, private libp2pPeerId: PeerId) {}
|
constructor(
|
||||||
|
private privateKey: PrivateKey | undefined,
|
||||||
|
private publicKey: PublicKey,
|
||||||
|
private libp2pPeerId: PeerId
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates new KeyPair from ed25519 private key represented as a 32 byte array
|
* Generates new KeyPair from ed25519 private key represented as a 32 byte array
|
||||||
@ -43,7 +47,7 @@ export class KeyPair {
|
|||||||
static async fromEd25519SK(seed: Uint8Array): Promise<KeyPair> {
|
static async fromEd25519SK(seed: Uint8Array): Promise<KeyPair> {
|
||||||
const key = await generateKeyPairFromSeed('Ed25519', seed, 256);
|
const key = await generateKeyPairFromSeed('Ed25519', seed, 256);
|
||||||
const lib2p2Pid = await createFromPrivKey(key);
|
const lib2p2Pid = await createFromPrivKey(key);
|
||||||
return new KeyPair(key, lib2p2Pid);
|
return new KeyPair(key, key.public, lib2p2Pid);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -53,7 +57,7 @@ export class KeyPair {
|
|||||||
static async randomEd25519(): Promise<KeyPair> {
|
static async randomEd25519(): Promise<KeyPair> {
|
||||||
const key = await generateKeyPair('Ed25519');
|
const key = await generateKeyPair('Ed25519');
|
||||||
const lib2p2Pid = await createFromPrivKey(key);
|
const lib2p2Pid = await createFromPrivKey(key);
|
||||||
return new KeyPair(key, lib2p2Pid);
|
return new KeyPair(key, key.public, lib2p2Pid);
|
||||||
}
|
}
|
||||||
|
|
||||||
getPeerId(): string {
|
getPeerId(): string {
|
||||||
@ -64,15 +68,21 @@ export class KeyPair {
|
|||||||
* @returns 32 byte private key
|
* @returns 32 byte private key
|
||||||
*/
|
*/
|
||||||
toEd25519PrivateKey(): Uint8Array {
|
toEd25519PrivateKey(): Uint8Array {
|
||||||
return this.key.marshal().subarray(0, 32);
|
if (this.privateKey === undefined) {
|
||||||
|
throw new Error('Private key not supplied');
|
||||||
|
}
|
||||||
|
return this.privateKey.marshal().subarray(0, 32);
|
||||||
}
|
}
|
||||||
|
|
||||||
signBytes(data: Uint8Array): Promise<Uint8Array> {
|
signBytes(data: Uint8Array): Promise<Uint8Array> {
|
||||||
return this.key.sign(data);
|
if (this.privateKey === undefined) {
|
||||||
|
throw new Error('Private key not supplied');
|
||||||
|
}
|
||||||
|
return this.privateKey.sign(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
verify(data: Uint8Array, signature: Uint8Array): Promise<boolean> {
|
verify(data: Uint8Array, signature: Uint8Array): Promise<boolean> {
|
||||||
return this.key.public.verify(data, signature);
|
return this.publicKey.verify(data, signature);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -14,15 +14,17 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { fromUint8Array, toUint8Array } from 'js-base64';
|
import { atob, fromUint8Array, toUint8Array } from 'js-base64';
|
||||||
import { CallResultsArray } from '@fluencelabs/avm';
|
import { CallResultsArray } from '@fluencelabs/avm';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { Buffer } from 'buffer';
|
import { Buffer } from 'buffer';
|
||||||
import { IParticle } from './interfaces.js';
|
import { IParticle } from './interfaces.js';
|
||||||
|
import { concat } from 'uint8arrays/concat';
|
||||||
|
import { numberToLittleEndianBytes } from '../util/bytes.js';
|
||||||
|
import { KeyPair } from '../keypair/index.js';
|
||||||
|
import { unmarshalPublicKey } from '@libp2p/crypto/keys';
|
||||||
|
|
||||||
export class Particle implements IParticle {
|
export class Particle implements IParticle {
|
||||||
readonly signature: undefined;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public readonly id: string,
|
public readonly id: string,
|
||||||
public readonly timestamp: number,
|
public readonly timestamp: number,
|
||||||
@ -30,12 +32,15 @@ export class Particle implements IParticle {
|
|||||||
public readonly data: Uint8Array,
|
public readonly data: Uint8Array,
|
||||||
public readonly ttl: number,
|
public readonly ttl: number,
|
||||||
public readonly initPeerId: string,
|
public readonly initPeerId: string,
|
||||||
) {
|
public readonly signature: Uint8Array
|
||||||
this.signature = undefined;
|
) {}
|
||||||
}
|
|
||||||
|
|
||||||
static createNew(script: string, initPeerId: string, ttl: number): Particle {
|
static async createNew(script: string, initPeerId: string, ttl: number, keyPair: KeyPair): Promise<Particle> {
|
||||||
return new Particle(uuidv4(), Date.now(), script, Buffer.from([]), ttl, initPeerId);
|
const id = uuidv4();
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const message = buildParticleMessage({ id, timestamp, ttl, script });
|
||||||
|
const signature = await keyPair.signBytes(message);
|
||||||
|
return new Particle(id, Date.now(), script, Buffer.from([]), ttl, initPeerId, signature);
|
||||||
}
|
}
|
||||||
|
|
||||||
static fromString(str: string): Particle {
|
static fromString(str: string): Particle {
|
||||||
@ -47,12 +52,27 @@ export class Particle implements IParticle {
|
|||||||
toUint8Array(json.data),
|
toUint8Array(json.data),
|
||||||
json.ttl,
|
json.ttl,
|
||||||
json.init_peer_id,
|
json.init_peer_id,
|
||||||
|
new Uint8Array(json.signature)
|
||||||
);
|
);
|
||||||
|
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const en = new TextEncoder();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds particle message for signing
|
||||||
|
*/
|
||||||
|
export const buildParticleMessage = ({ id, timestamp, ttl, script }: Omit<IParticle, 'initPeerId' | 'signature' | 'data'>): Uint8Array => {
|
||||||
|
return concat([
|
||||||
|
en.encode(id),
|
||||||
|
numberToLittleEndianBytes(timestamp, 'u64'),
|
||||||
|
numberToLittleEndianBytes(ttl, 'u32'),
|
||||||
|
en.encode(script),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns actual ttl of a particle, i.e. ttl - time passed since particle creation
|
* Returns actual ttl of a particle, i.e. ttl - time passed since particle creation
|
||||||
*/
|
*/
|
||||||
@ -67,11 +87,21 @@ export const hasExpired = (particle: IParticle): boolean => {
|
|||||||
return getActualTTL(particle) <= 0;
|
return getActualTTL(particle) <= 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates that particle signature is correct
|
||||||
|
*/
|
||||||
|
export const verifySignature = async (particle: IParticle, publicKey: Uint8Array): Promise<boolean> => {
|
||||||
|
// TODO: Uncomment this when nox roll out particle signatures
|
||||||
|
return true;
|
||||||
|
// const message = buildParticleMessage(particle);
|
||||||
|
// return unmarshalPublicKey(publicKey).verify(message, particle.signature);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a particle clone with new data
|
* Creates a particle clone with new data
|
||||||
*/
|
*/
|
||||||
export const cloneWithNewData = (particle: IParticle, newData: Uint8Array): IParticle => {
|
export const cloneWithNewData = (particle: IParticle, newData: Uint8Array): IParticle => {
|
||||||
return new Particle(particle.id, particle.timestamp, particle.script, newData, particle.ttl, particle.initPeerId);
|
return new Particle(particle.id, particle.timestamp, particle.script, newData, particle.ttl, particle.initPeerId, particle.signature);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -92,8 +122,7 @@ export const serializeToString = (particle: IParticle): string => {
|
|||||||
timestamp: particle.timestamp,
|
timestamp: particle.timestamp,
|
||||||
ttl: particle.ttl,
|
ttl: particle.ttl,
|
||||||
script: particle.script,
|
script: particle.script,
|
||||||
// TODO: copy signature from a particle after signatures will be implemented on nodes
|
signature: Array.from(particle.signature),
|
||||||
signature: [],
|
|
||||||
data: particle.data && fromUint8Array(particle.data),
|
data: particle.data && fromUint8Array(particle.data),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -44,8 +44,10 @@ export interface IImmutableParticlePart {
|
|||||||
*/
|
*/
|
||||||
readonly initPeerId: PeerIdB58;
|
readonly initPeerId: PeerIdB58;
|
||||||
|
|
||||||
// TODO: implement particle signatures
|
/**
|
||||||
readonly signature: undefined;
|
* Particle's signature of concatenation of bytes of all immutable particle fields.
|
||||||
|
*/
|
||||||
|
readonly signature: Uint8Array;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -79,7 +79,7 @@ export class Sig implements SigDef {
|
|||||||
* Verifies the signature. Required by aqua
|
* Verifies the signature. Required by aqua
|
||||||
*/
|
*/
|
||||||
verify(signature: number[], data: number[]): Promise<boolean> {
|
verify(signature: number[], data: number[]): Promise<boolean> {
|
||||||
return this.keyPair.verify(Uint8Array.from(data), Uint8Array.from(signature));
|
return this.keyPair.verify(Uint8Array.from(data), Uint8Array.from(signature))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -122,7 +122,7 @@ describe('Tests for default handler', () => {
|
|||||||
initPeerId: 'init peer id',
|
initPeerId: 'init peer id',
|
||||||
timestamp: 595951200,
|
timestamp: 595951200,
|
||||||
ttl: 595961200,
|
ttl: 595961200,
|
||||||
signature: 'sig',
|
signature: new Uint8Array([]),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -156,7 +156,7 @@ describe('Tests for default handler', () => {
|
|||||||
initPeerId: 'init peer id',
|
initPeerId: 'init peer id',
|
||||||
timestamp: 595951200,
|
timestamp: 595951200,
|
||||||
ttl: 595961200,
|
ttl: 595961200,
|
||||||
signature: 'sig',
|
signature: new Uint8Array([]),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -56,7 +56,7 @@ describe('Sig service test suite', () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
const p = peer.internals.createNewParticle(script);
|
const p = await peer.internals.createNewParticle(script);
|
||||||
await peer.internals.initiateParticle(p, doNothing);
|
await peer.internals.initiateParticle(p, doNothing);
|
||||||
|
|
||||||
const [nestedFirst, nestedSecond, outerFirst, outerSecond, outerFirstString, outerFirstParsed] = await promise;
|
const [nestedFirst, nestedSecond, outerFirst, outerSecond, outerFirstString, outerFirstParsed] = await promise;
|
||||||
|
34
packages/core/js-client/src/util/bytes.ts
Normal file
34
packages/core/js-client/src/util/bytes.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2023 Fluence Labs Limited
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Size = 'u32' | 'u64';
|
||||||
|
|
||||||
|
const sizeMap = {
|
||||||
|
'u32': 4,
|
||||||
|
'u64': 8
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function numberToBytes(n: number, s: Size, littleEndian: boolean) {
|
||||||
|
const size = sizeMap[s];
|
||||||
|
const buffer = new ArrayBuffer(size);
|
||||||
|
const dv = new DataView(buffer);
|
||||||
|
dv.setUint32(0, n, littleEndian);
|
||||||
|
return new Uint8Array(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function numberToLittleEndianBytes(n: number, s: Size) {
|
||||||
|
return numberToBytes(n, s, true);
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user